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.
Files changed (103) hide show
  1. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/CHANGELOG.md +2 -0
  2. kiba_core-0.5.3.dev52/PKG-INFO +41 -0
  3. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/authorizer.py +13 -19
  4. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/json_route.py +4 -9
  5. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/streaming_json_route.py +3 -7
  6. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/logging.py +9 -10
  7. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/aqs.py +4 -3
  8. kiba_core-0.5.3.dev52/core/queues/cosmos.py +190 -0
  9. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/model.py +15 -3
  10. kiba_core-0.5.3.dev52/core/queues/sql.py +165 -0
  11. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/sqs.py +5 -4
  12. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/s3_manager.py +2 -2
  13. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/database.py +7 -2
  14. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/retriever.py +1 -0
  15. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/json_util.py +5 -5
  16. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/eth_client.py +7 -5
  17. kiba_core-0.5.3.dev52/kiba_core.egg-info/PKG-INFO +41 -0
  18. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/SOURCES.txt +2 -0
  19. kiba_core-0.5.3.dev52/kiba_core.egg-info/requires.txt +44 -0
  20. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/makefile +3 -4
  21. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/pyproject.toml +23 -20
  22. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/web3/test_rest_eth_client.py +75 -0
  23. kiba_core-0.5.3.dev52/uv.lock +3312 -0
  24. kiba_core-0.5.3.dev50/PKG-INFO +0 -39
  25. kiba_core-0.5.3.dev50/kiba_core.egg-info/PKG-INFO +0 -39
  26. kiba_core-0.5.3.dev50/kiba_core.egg-info/requires.txt +0 -41
  27. kiba_core-0.5.3.dev50/uv.lock +0 -3111
  28. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/pull_request_template.md +0 -0
  29. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/deploy.yml +0 -0
  30. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/pull-request.yml +0 -0
  31. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.github/workflows/release.yml +0 -0
  32. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/.gitignore +0 -0
  33. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/Dockerfile +0 -0
  34. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/README.md +0 -0
  35. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/__init__.py +0 -0
  36. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/__init__.py +0 -0
  37. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/api_request.py +0 -0
  38. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/api_response.py +0 -0
  39. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/default_routes.py +0 -0
  40. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/health.py +0 -0
  41. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/__init__.py +0 -0
  42. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/database_connection_middleware.py +0 -0
  43. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/exception_handling_middleware.py +0 -0
  44. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/logging_middleware.py +0 -0
  45. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/api/middleware/server_headers_middleware.py +0 -0
  46. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/aws_requester.py +0 -0
  47. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/__init__.py +0 -0
  48. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/cache.py +0 -0
  49. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/dict_cache.py +0 -0
  50. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/caching/file_cache.py +0 -0
  51. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/exceptions.py +0 -0
  52. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/basic_authentication.py +0 -0
  54. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/jwt.py +0 -0
  55. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/http/rest_method.py +0 -0
  56. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/__init__.py +0 -0
  57. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/discord_client.py +0 -0
  58. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/notification_client.py +0 -0
  59. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/notifications/slack_client.py +0 -0
  60. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/py.typed +0 -0
  61. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/message_queue.py +0 -0
  63. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/queues/message_queue_processor.py +0 -0
  64. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/requester/__init__.py +0 -0
  65. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/requester/requester.py +0 -0
  66. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/service_client.py +0 -0
  67. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/__init__.py +0 -0
  68. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/store/saver.py +0 -0
  69. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/__init__.py +0 -0
  70. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/async_util.py +0 -0
  71. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/chain_util.py +0 -0
  72. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/date_util.py +0 -0
  73. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/dict_util.py +0 -0
  74. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/file_util.py +0 -0
  75. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/hashing_util.py +0 -0
  76. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/http_util.py +0 -0
  77. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/list_util.py +0 -0
  78. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/string_util.py +0 -0
  79. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/typing_util.py +0 -0
  80. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/url_util.py +0 -0
  81. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/util/value_holder.py +0 -0
  82. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/__init__.py +0 -0
  83. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/core/web3/multicall3.py +0 -0
  84. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/dependency_links.txt +0 -0
  85. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/kiba_core.egg-info/top_level.txt +0 -0
  86. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/setup.cfg +0 -0
  87. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/__init__.py +0 -0
  88. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_authorizer.py +0 -0
  89. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_json_route.py +0 -0
  90. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/api/test_streaming_json_route.py +0 -0
  91. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/caching/test_dict_cache.py +0 -0
  92. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/caching/test_file_cache.py +0 -0
  93. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/__init__.py +0 -0
  94. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_async_util.py +0 -0
  95. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_chain_util.py +0 -0
  96. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_date_util.py +0 -0
  97. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_dict_util.py +0 -0
  98. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_file_util.py +0 -0
  99. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_json_util.py +0 -0
  100. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_list_util.py +0 -0
  101. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_string_util.py +0 -0
  102. {kiba_core-0.5.3.dev50 → kiba_core-0.5.3.dev52}/tests/util/test_url_util.py +0 -0
  103. {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[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]], typing.Callable[_P, typing.Any]]:
47
- def decorator(func: typing.Callable[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]) -> typing.Callable[_P, typing.Any]: # type: ignore[explicit-any]
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=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
- # TODO(krishan711): figure out correct typing here
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[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]], typing.Callable[_P, typing.Any]]:
83
- def decorator(func: typing.Callable[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]) -> typing.Callable[_P, typing.Any]: # type: ignore[explicit-any]
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=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
- # TODO(krishan711): figure out correct typing here
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[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]], typing.Callable[_P, typing.Any]]:
116
- def decorator(func: typing.Callable[[Arg(KibaApiRequest[ApiRequest], 'request')], _AnyReturn]) -> typing.Callable[_P, typing.Any]: # type: ignore[explicit-any]
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=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
- # TODO(krishan711): figure out correct typing here
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[[Arg(KibaApiRequest[ApiRequest], 'request')], typing.Awaitable[ApiResponse]]], typing.Callable[_P, KibaJSONResponse]]:
23
- def decorator(func: typing.Callable[[Arg(KibaApiRequest[ApiRequest], 'request')], typing.Awaitable[ApiResponse]]) -> typing.Callable[_P, KibaJSONResponse]:
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(request=kibaRequest)
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
- # TODO(krishan711): figure out correct typing here
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[_P, StreamingResponse]]:
31
- def decorator(func: typing.Callable[[KibaApiRequest[ApiRequest]], AsyncIterator[ApiResponse]]) -> typing.Callable[_P, StreamingResponse]:
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
- # TODO(krishan711): figure out correct typing here
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[None, float]) -> str:
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=typing.cast(dict[str, str], {'statName': nameValue, 'statKey': keyValue, 'statValue': statValue}))
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=typing.cast(dict[str, str], {'apiAction': actionString, 'apiPath': pathString, 'apiPathPattern': pathPatternString, 'apiQuery': queryString, 'apiResponse': responseString or '', 'apiDuration': durationString or ''}),
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(level=CRITICAL, msg=msg, *args, **kwargs) # type: ignore[misc] # noqa: B026
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(level=ERROR, msg=msg, *args, **kwargs) # type: ignore[misc] # noqa: B026
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(level=ERROR, msg=resolvedMessage, exc_info=exc_info, *args, **kwargs) # type: ignore[misc] # noqa: B026
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(level=WARNING, msg=msg, *args, **kwargs) # type: ignore[misc] # noqa: B026
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(level=INFO, msg=msg, *args, **kwargs) # type: ignore[misc] # noqa: B026
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(level=DEBUG, msg=msg, *args, **kwargs) # type: ignore[misc] # noqa: B026
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): # type: ignore[explicit-any]
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.parse_raw(aqsMessage.content)
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.json())
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): # 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