kiba-core 0.5.3.dev50__tar.gz → 0.5.3.dev52__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.dev50 → kiba_core-0.5.3.dev52}/CHANGELOG.md +2 -0
- kiba_core-0.5.3.dev52/PKG-INFO +41 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/authorizer.py +13 -19
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/json_route.py +4 -9
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/streaming_json_route.py +3 -7
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/logging.py +9 -10
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/aqs.py +4 -3
- kiba_core-0.5.3.dev52/core/queues/cosmos.py +190 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/model.py +15 -3
- kiba_core-0.5.3.dev52/core/queues/sql.py +165 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/sqs.py +5 -4
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/s3_manager.py +2 -2
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/database.py +7 -2
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/retriever.py +1 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/json_util.py +5 -5
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/eth_client.py +7 -5
- kiba_core-0.5.3.dev52/kiba_core.egg-info/PKG-INFO +41 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/SOURCES.txt +2 -0
- kiba_core-0.5.3.dev52/kiba_core.egg-info/requires.txt +44 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/makefile +3 -4
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/pyproject.toml +23 -20
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/web3/test_rest_eth_client.py +75 -0
- kiba_core-0.5.3.dev52/uv.lock +3312 -0
- kiba_core-0.5.3.dev50/PKG-INFO +0 -39
- kiba_core-0.5.3.dev50/kiba_core.egg-info/PKG-INFO +0 -39
- kiba_core-0.5.3.dev50/kiba_core.egg-info/requires.txt +0 -41
- kiba_core-0.5.3.dev50/uv.lock +0 -3111
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/pull-request.yml +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.gitignore +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/README.md +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/message_queue.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_json_route.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/web3/__init__.py +0 -0
|
@@ -32,6 +32,8 @@ 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
|
|
36
|
+
- [MINOR] Added `shouldAllowFailures` to `EthClient.multicall` to allow individual calls to fail without reverting the whole batch
|
|
35
37
|
|
|
36
38
|
### Changed
|
|
37
39
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kiba-core
|
|
3
|
+
Version: 0.5.3.dev52
|
|
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.141.1; extra == "api"
|
|
13
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "api"
|
|
14
|
+
Provides-Extra: core-api
|
|
15
|
+
Requires-Dist: starlette>=1.6.0; extra == "core-api"
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.51.0; extra == "core-api"
|
|
17
|
+
Provides-Extra: storage
|
|
18
|
+
Requires-Dist: aiobotocore>=3.9.0; extra == "storage"
|
|
19
|
+
Provides-Extra: queues
|
|
20
|
+
Requires-Dist: aiobotocore>=3.9.0; extra == "queues"
|
|
21
|
+
Provides-Extra: queue-sqs
|
|
22
|
+
Requires-Dist: aiobotocore>=3.9.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.3; extra == "queue-cosmos"
|
|
27
|
+
Provides-Extra: database-psql
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.52; 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.52; 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.9.0; extra == "types"
|
|
39
|
+
Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "types"
|
|
40
|
+
|
|
41
|
+
# Core-Py
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import typing
|
|
3
3
|
from collections.abc import AsyncIterator
|
|
4
|
-
from typing import ParamSpec
|
|
5
4
|
|
|
6
|
-
from mypy_extensions import Arg
|
|
7
5
|
from pydantic import BaseModel
|
|
8
6
|
|
|
9
7
|
from core import logging
|
|
@@ -13,7 +11,6 @@ from core.exceptions import UnauthorizedException
|
|
|
13
11
|
from core.http.basic_authentication import BasicAuthentication
|
|
14
12
|
from core.http.jwt import Jwt
|
|
15
13
|
|
|
16
|
-
_P = ParamSpec('_P')
|
|
17
14
|
_AnyReturn = typing.Awaitable[typing.Any] | AsyncIterator[typing.Any] # type: ignore[explicit-any]
|
|
18
15
|
|
|
19
16
|
|
|
@@ -43,19 +40,18 @@ async def _authorize_bearer_jwt[ApiRequest: BaseModel](request: KibaApiRequest[A
|
|
|
43
40
|
|
|
44
41
|
def authorize_bearer_jwt[ApiRequest: BaseModel]( # type: ignore[explicit-any]
|
|
45
42
|
authorizer: Authorizer,
|
|
46
|
-
) -> typing.Callable[[typing.Callable[[
|
|
47
|
-
def decorator(func: typing.Callable[[
|
|
43
|
+
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]], typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]]:
|
|
44
|
+
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]) -> typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]: # type: ignore[explicit-any]
|
|
48
45
|
@functools.wraps(func)
|
|
49
46
|
async def async_wrapper(request: KibaApiRequest[ApiRequest]) -> typing.Any: # type: ignore[explicit-any, misc]
|
|
50
47
|
request.authJwt = await _authorize_bearer_jwt(request=request, authorizer=authorizer)
|
|
51
|
-
result = func(request
|
|
48
|
+
result = func(request)
|
|
52
49
|
# NOTE(krishan711): this is here to support streaming responses which return an async generator
|
|
53
50
|
if hasattr(result, '__aiter__'):
|
|
54
51
|
return result
|
|
55
52
|
return await result
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
return async_wrapper # type: ignore[return-value]
|
|
54
|
+
return async_wrapper
|
|
59
55
|
|
|
60
56
|
return decorator
|
|
61
57
|
|
|
@@ -72,26 +68,25 @@ async def get_basic_authentication_from_authorization_signature[ApiRequest: Base
|
|
|
72
68
|
except UnauthorizedException:
|
|
73
69
|
raise
|
|
74
70
|
except BaseException as exception: # noqa: BLE001
|
|
75
|
-
logging.exception(exception) # type: ignore[arg-type]
|
|
71
|
+
logging.exception(exception) # type: ignore[arg-type, ty:invalid-argument-type]
|
|
76
72
|
raise ForbiddenException(message='AUTH_INVALID')
|
|
77
73
|
return BasicAuthentication(username=signerId, password=signatureString)
|
|
78
74
|
|
|
79
75
|
|
|
80
76
|
def authorize_signature[ApiRequest: BaseModel]( # type: ignore[explicit-any]
|
|
81
77
|
authorizer: SignatureAuthorizer,
|
|
82
|
-
) -> typing.Callable[[typing.Callable[[
|
|
83
|
-
def decorator(func: typing.Callable[[
|
|
78
|
+
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]], typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]]:
|
|
79
|
+
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]) -> typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]: # type: ignore[explicit-any]
|
|
84
80
|
@functools.wraps(func)
|
|
85
81
|
async def async_wrapper(request: KibaApiRequest[ApiRequest]) -> typing.Any: # type: ignore[explicit-any, misc]
|
|
86
82
|
request.authBasic = await get_basic_authentication_from_authorization_signature(request=request, authorizer=authorizer)
|
|
87
|
-
result = func(request
|
|
83
|
+
result = func(request)
|
|
88
84
|
# NOTE(krishan711): this is here to support streaming responses which return an async generator
|
|
89
85
|
if hasattr(result, '__aiter__'):
|
|
90
86
|
return result
|
|
91
87
|
return await result
|
|
92
88
|
|
|
93
|
-
|
|
94
|
-
return async_wrapper # type: ignore[return-value]
|
|
89
|
+
return async_wrapper
|
|
95
90
|
|
|
96
91
|
return decorator
|
|
97
92
|
|
|
@@ -112,8 +107,8 @@ class StaticTokenAuthorizer(TokenAuthorizer):
|
|
|
112
107
|
|
|
113
108
|
def authorize_token[ApiRequest: BaseModel]( # type: ignore[explicit-any]
|
|
114
109
|
authorizer: TokenAuthorizer,
|
|
115
|
-
) -> typing.Callable[[typing.Callable[[
|
|
116
|
-
def decorator(func: typing.Callable[[
|
|
110
|
+
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]], typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]]:
|
|
111
|
+
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], _AnyReturn]) -> typing.Callable[[KibaApiRequest[ApiRequest]], typing.Any]: # type: ignore[explicit-any]
|
|
117
112
|
@functools.wraps(func)
|
|
118
113
|
async def async_wrapper(request: KibaApiRequest[ApiRequest]) -> typing.Any: # type: ignore[explicit-any, misc]
|
|
119
114
|
authorization = request.headers.get('Authorization')
|
|
@@ -122,13 +117,12 @@ def authorize_token[ApiRequest: BaseModel]( # type: ignore[explicit-any]
|
|
|
122
117
|
if not authorization.startswith('Token '):
|
|
123
118
|
raise ForbiddenException(message='AUTH_INVALID')
|
|
124
119
|
await authorizer.validate_token(authorization[6:])
|
|
125
|
-
result = func(request
|
|
120
|
+
result = func(request)
|
|
126
121
|
# NOTE(krishan711): this is here to support streaming responses which return an async generator
|
|
127
122
|
if hasattr(result, '__aiter__'):
|
|
128
123
|
return result
|
|
129
124
|
return await result
|
|
130
125
|
|
|
131
|
-
|
|
132
|
-
return async_wrapper # type: ignore[return-value]
|
|
126
|
+
return async_wrapper
|
|
133
127
|
|
|
134
128
|
return decorator
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
import typing
|
|
3
|
-
from typing import ParamSpec
|
|
4
3
|
|
|
5
|
-
from mypy_extensions import Arg
|
|
6
4
|
from pydantic import BaseModel
|
|
7
5
|
from pydantic import ValidationError
|
|
8
6
|
|
|
@@ -13,14 +11,12 @@ from core.exceptions import InternalServerErrorException
|
|
|
13
11
|
from core.util import json_util
|
|
14
12
|
from core.util.typing_util import JsonObject
|
|
15
13
|
|
|
16
|
-
_P = ParamSpec('_P')
|
|
17
|
-
|
|
18
14
|
|
|
19
15
|
def json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
20
16
|
requestType: typing.Type[ApiRequest],
|
|
21
17
|
responseType: typing.Type[ApiResponse],
|
|
22
|
-
) -> typing.Callable[[typing.Callable[[
|
|
23
|
-
def decorator(func: typing.Callable[[
|
|
18
|
+
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], typing.Awaitable[ApiResponse]]], typing.Callable[..., typing.Awaitable[KibaJSONResponse]]]:
|
|
19
|
+
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], typing.Awaitable[ApiResponse]]) -> typing.Callable[..., typing.Awaitable[KibaJSONResponse]]:
|
|
24
20
|
@functools.wraps(func)
|
|
25
21
|
async def async_wrapper(*args: typing.Any) -> KibaJSONResponse: # type: ignore[explicit-any, misc]
|
|
26
22
|
receivedRequest = args[0]
|
|
@@ -42,12 +38,11 @@ def json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
|
42
38
|
raise BadRequestException(f'Invalid request: {validationErrorMessage}')
|
|
43
39
|
kibaRequest: KibaApiRequest[ApiRequest] = KibaApiRequest(scope=receivedRequest.scope, receive=receivedRequest._receive, send=receivedRequest._send) # noqa: SLF001
|
|
44
40
|
kibaRequest.data = requestParams
|
|
45
|
-
receivedResponse = await func(
|
|
41
|
+
receivedResponse = await func(kibaRequest)
|
|
46
42
|
if not isinstance(receivedResponse, responseType):
|
|
47
43
|
raise InternalServerErrorException(f'Expected response to be of type {responseType}, got {type(receivedResponse)}')
|
|
48
44
|
return KibaJSONResponse(content=receivedResponse.model_dump())
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
return async_wrapper # type: ignore[return-value]
|
|
46
|
+
return async_wrapper
|
|
52
47
|
|
|
53
48
|
return decorator
|
|
@@ -2,7 +2,6 @@ import functools
|
|
|
2
2
|
import inspect
|
|
3
3
|
import typing
|
|
4
4
|
from collections.abc import AsyncIterator
|
|
5
|
-
from typing import ParamSpec
|
|
6
5
|
|
|
7
6
|
from pydantic import BaseModel
|
|
8
7
|
from pydantic import ValidationError
|
|
@@ -14,8 +13,6 @@ from core.exceptions import InternalServerErrorException
|
|
|
14
13
|
from core.util import json_util
|
|
15
14
|
from core.util.typing_util import JsonObject
|
|
16
15
|
|
|
17
|
-
_P = ParamSpec('_P')
|
|
18
|
-
|
|
19
16
|
|
|
20
17
|
async def _convert_to_json_generator[T: BaseModel](response_iterator: AsyncIterator[T], expectedType: typing.Type[T]) -> AsyncIterator[bytes]:
|
|
21
18
|
async for content in response_iterator:
|
|
@@ -27,8 +24,8 @@ async def _convert_to_json_generator[T: BaseModel](response_iterator: AsyncItera
|
|
|
27
24
|
def streaming_json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
28
25
|
requestType: typing.Type[ApiRequest],
|
|
29
26
|
responseType: typing.Type[ApiResponse],
|
|
30
|
-
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], AsyncIterator[ApiResponse]]], typing.Callable[
|
|
31
|
-
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], AsyncIterator[ApiResponse]]) -> typing.Callable[
|
|
27
|
+
) -> typing.Callable[[typing.Callable[[KibaApiRequest[ApiRequest]], AsyncIterator[ApiResponse]]], typing.Callable[..., typing.Awaitable[StreamingResponse]]]:
|
|
28
|
+
def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], AsyncIterator[ApiResponse]]) -> typing.Callable[..., typing.Awaitable[StreamingResponse]]:
|
|
32
29
|
@functools.wraps(func)
|
|
33
30
|
async def async_wrapper(*args: typing.Any, **kwargs: typing.Any) -> StreamingResponse: # type: ignore[explicit-any, misc]
|
|
34
31
|
receivedRequest = kwargs.get('request', args[0] if args else None)
|
|
@@ -58,7 +55,6 @@ def streaming_json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
|
|
|
58
55
|
# NOTE(krishan711): we set content-encoding to identity to prevent gzip from trying to process it (cos it buffers all the content)
|
|
59
56
|
return StreamingResponse(content=wrappedGenerator, media_type='application/x-ndjson', headers={'Content-Encoding': 'identity'})
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
return async_wrapper # type: ignore[return-value]
|
|
58
|
+
return async_wrapper
|
|
63
59
|
|
|
64
60
|
return decorator
|
|
@@ -4,7 +4,6 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
|
-
import typing
|
|
8
7
|
from collections.abc import Callable
|
|
9
8
|
from collections.abc import Collection
|
|
10
9
|
from logging import Formatter
|
|
@@ -153,7 +152,7 @@ def init_basic_logging(showDebug: bool = False) -> None:
|
|
|
153
152
|
init_logger(logger=logger, loggingLevel=loggingLevel, handler=handler)
|
|
154
153
|
|
|
155
154
|
|
|
156
|
-
def _serialize_numeric_value(value: Union[
|
|
155
|
+
def _serialize_numeric_value(value: Union[float, None]) -> str:
|
|
157
156
|
if value is None:
|
|
158
157
|
return ''
|
|
159
158
|
roundedNumber = round(value, 6)
|
|
@@ -169,7 +168,7 @@ def stat(name: str, key: str, value: float = 1) -> None:
|
|
|
169
168
|
nameValue = _serialize_string_value(value=str(name))
|
|
170
169
|
keyValue = _serialize_string_value(value=str(key))
|
|
171
170
|
statValue = _serialize_numeric_value(value=value)
|
|
172
|
-
STAT_LOGGER.log(level=logging.INFO, msg='', extra=
|
|
171
|
+
STAT_LOGGER.log(level=logging.INFO, msg='', extra={'statName': nameValue, 'statKey': keyValue, 'statValue': statValue})
|
|
173
172
|
|
|
174
173
|
|
|
175
174
|
# TODO(krishan711): make pathPattern mandatory in next major release
|
|
@@ -184,7 +183,7 @@ def api(action: str, path: str, query: str, pathPattern: str | None = None, resp
|
|
|
184
183
|
API_LOGGER.log(
|
|
185
184
|
level=logging.INFO,
|
|
186
185
|
msg='',
|
|
187
|
-
extra=
|
|
186
|
+
extra={'apiAction': actionString, 'apiPath': pathString, 'apiPathPattern': pathPatternString, 'apiQuery': queryString, 'apiResponse': responseString or '', 'apiDuration': durationString or ''},
|
|
188
187
|
)
|
|
189
188
|
|
|
190
189
|
|
|
@@ -204,11 +203,11 @@ def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # type: igno
|
|
|
204
203
|
|
|
205
204
|
|
|
206
205
|
def critical(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
207
|
-
_log(
|
|
206
|
+
_log(CRITICAL, msg, *args, **kwargs)
|
|
208
207
|
|
|
209
208
|
|
|
210
209
|
def error(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
211
|
-
_log(
|
|
210
|
+
_log(ERROR, msg, *args, **kwargs)
|
|
212
211
|
|
|
213
212
|
|
|
214
213
|
def exception(msg: Union[str, Exception], *args: Any, exc_info: bool = True, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
@@ -218,19 +217,19 @@ def exception(msg: Union[str, Exception], *args: Any, exc_info: bool = True, **k
|
|
|
218
217
|
resolvedMessage = str(msg)
|
|
219
218
|
else:
|
|
220
219
|
resolvedMessage = msg
|
|
221
|
-
_log(
|
|
220
|
+
_log(ERROR, resolvedMessage, *args, exc_info=exc_info, **kwargs)
|
|
222
221
|
|
|
223
222
|
|
|
224
223
|
def warning(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
225
|
-
_log(
|
|
224
|
+
_log(WARNING, msg, *args, **kwargs)
|
|
226
225
|
|
|
227
226
|
|
|
228
227
|
def info(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
229
|
-
_log(
|
|
228
|
+
_log(INFO, msg, *args, **kwargs)
|
|
230
229
|
|
|
231
230
|
|
|
232
231
|
def debug(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
233
|
-
_log(
|
|
232
|
+
_log(DEBUG, msg, *args, **kwargs)
|
|
234
233
|
|
|
235
234
|
|
|
236
235
|
def basicConfig(**kwargs: Any) -> None: # type: ignore[explicit-any] # noqa: N802 # noqa: B026
|
|
@@ -12,19 +12,20 @@ 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
|
|
|
19
19
|
@classmethod
|
|
20
20
|
def from_aqs_message(cls, aqsMessage: RawAqsMessage) -> AqsMessage:
|
|
21
|
-
message = Message.
|
|
21
|
+
message = Message.model_validate_json(aqsMessage.content)
|
|
22
22
|
return cls(
|
|
23
23
|
command=message.command,
|
|
24
24
|
content=message.content,
|
|
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
|
)
|
|
@@ -53,7 +54,7 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
|
|
|
53
54
|
if not self._aqsClient:
|
|
54
55
|
raise InternalServerErrorException('You need to call .connect() before trying to send messages')
|
|
55
56
|
message.prepare_for_send()
|
|
56
|
-
await self._aqsClient.send_message(visibility_timeout=delaySeconds, content=message.
|
|
57
|
+
await self._aqsClient.send_message(visibility_timeout=delaySeconds, content=message.model_dump_json())
|
|
57
58
|
|
|
58
59
|
async def send_messages(self, messages: Sequence[Message], delaySeconds: int = 0) -> None:
|
|
59
60
|
if not self._aqsClient:
|
|
@@ -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
|