kiba-core 0.5.3.dev55__tar.gz → 0.5.3.dev56__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 (106) hide show
  1. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/CHANGELOG.md +2 -0
  2. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/default_routes.py +3 -2
  4. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/exception_handling_middleware.py +2 -2
  5. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/database.py +3 -0
  6. kiba_core-0.5.3.dev56/core/util/typing_util.py +5 -0
  7. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/PKG-INFO +1 -1
  8. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/SOURCES.txt +6 -0
  9. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/pyproject.toml +1 -1
  10. kiba_core-0.5.3.dev56/tests/api/test_api_response.py +39 -0
  11. kiba_core-0.5.3.dev56/tests/api/test_exception_handling_middleware.py +109 -0
  12. kiba_core-0.5.3.dev56/tests/requester/test_requester.py +41 -0
  13. kiba_core-0.5.3.dev56/tests/test_database.py +121 -0
  14. kiba_core-0.5.3.dev56/tests/test_exceptions.py +71 -0
  15. kiba_core-0.5.3.dev56/tests/web3/__init__.py +0 -0
  16. kiba_core-0.5.3.dev55/core/util/typing_util.py +0 -7
  17. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/pull_request_template.md +0 -0
  18. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/deploy.yml +0 -0
  19. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/pull-request.yml +0 -0
  20. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.github/workflows/release.yml +0 -0
  21. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/.gitignore +0 -0
  22. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/Dockerfile +0 -0
  23. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/README.md +0 -0
  24. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/__init__.py +0 -0
  26. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/api_request.py +0 -0
  27. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/api_response.py +0 -0
  28. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/authorizer.py +0 -0
  29. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/health.py +0 -0
  30. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/json_route.py +0 -0
  31. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/__init__.py +0 -0
  32. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/database_connection_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/logging_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/middleware/server_headers_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/api/streaming_json_route.py +0 -0
  36. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/aws_requester.py +0 -0
  37. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/__init__.py +0 -0
  38. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/cache.py +0 -0
  39. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/dict_cache.py +0 -0
  40. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/caching/file_cache.py +0 -0
  41. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/exceptions.py +0 -0
  42. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/__init__.py +0 -0
  43. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/basic_authentication.py +0 -0
  44. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/jwt.py +0 -0
  45. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/http/rest_method.py +0 -0
  46. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/logging.py +0 -0
  47. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/__init__.py +0 -0
  48. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/discord_client.py +0 -0
  49. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/notification_client.py +0 -0
  50. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/notifications/slack_client.py +0 -0
  51. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/py.typed +0 -0
  52. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/aqs.py +0 -0
  54. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/cosmos.py +0 -0
  55. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/message_queue.py +0 -0
  56. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/message_queue_processor.py +0 -0
  57. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/model.py +0 -0
  58. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/sql.py +0 -0
  59. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/queues/sqs.py +0 -0
  60. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/requester/__init__.py +0 -0
  61. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/requester/requester.py +0 -0
  62. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/s3_manager.py +0 -0
  63. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/service_client.py +0 -0
  64. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/__init__.py +0 -0
  65. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/retriever.py +0 -0
  66. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/store/saver.py +0 -0
  67. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/__init__.py +0 -0
  68. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/async_util.py +0 -0
  69. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/chain_util.py +0 -0
  70. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/date_util.py +0 -0
  71. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/dict_util.py +0 -0
  72. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/file_util.py +0 -0
  73. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/hashing_util.py +0 -0
  74. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/http_util.py +0 -0
  75. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/json_util.py +0 -0
  76. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/list_util.py +0 -0
  77. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/string_util.py +0 -0
  78. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/url_util.py +0 -0
  79. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/util/value_holder.py +0 -0
  80. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/__init__.py +0 -0
  81. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/eth_client.py +0 -0
  82. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/core/web3/multicall3.py +0 -0
  83. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/dependency_links.txt +0 -0
  84. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/requires.txt +0 -0
  85. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/kiba_core.egg-info/top_level.txt +0 -0
  86. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/makefile +0 -0
  87. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/setup.cfg +0 -0
  88. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/__init__.py +0 -0
  89. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_authorizer.py +0 -0
  90. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_json_route.py +0 -0
  91. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/api/test_streaming_json_route.py +0 -0
  92. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/caching/test_dict_cache.py +0 -0
  93. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/caching/test_file_cache.py +0 -0
  94. {kiba_core-0.5.3.dev55/tests/web3 → kiba_core-0.5.3.dev56/tests/requester}/__init__.py +0 -0
  95. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/__init__.py +0 -0
  96. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_async_util.py +0 -0
  97. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_chain_util.py +0 -0
  98. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_date_util.py +0 -0
  99. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_dict_util.py +0 -0
  100. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_file_util.py +0 -0
  101. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_json_util.py +0 -0
  102. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_list_util.py +0 -0
  103. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_string_util.py +0 -0
  104. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/util/test_url_util.py +0 -0
  105. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/tests/web3/test_rest_eth_client.py +0 -0
  106. {kiba_core-0.5.3.dev55 → kiba_core-0.5.3.dev56}/uv.lock +0 -0
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MINOR] Use `json_util`/`orjson` for API and database JSON serialization
12
+ - [MINOR] Use Pydantic `JsonValue` for JSON type aliases
11
13
  - [MAJOR] Update web3 to 7.8.0
12
14
  - [MINOR] Added `call_function_by_name`, `send_transaction_by_name` and `wait_for_transaction_receipt` to `RestEthClient`
13
15
  - [MINOR] Updated `RestEthClient` to use `maxPriorityFeePerGas` and `maxFeePerGas` instead of `gasPrice`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev55
3
+ Version: 0.5.3.dev56
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -1,13 +1,14 @@
1
1
  from starlette.requests import Request
2
- from starlette.responses import JSONResponse
3
2
  from starlette.responses import Response
4
3
  from starlette.routing import BaseRoute
5
4
  from starlette.routing import Route
6
5
 
6
+ from core.api.api_response import KibaJSONResponse
7
+
7
8
 
8
9
  def create_default_routes(name: str, version: str, environment: str) -> list[BaseRoute]:
9
10
  async def root(request: Request) -> Response: # noqa: ARG001
10
- return JSONResponse(content={'server': name, 'version': version, 'environment': environment})
11
+ return KibaJSONResponse(content={'server': name, 'version': version, 'environment': environment})
11
12
 
12
13
  return [
13
14
  Route('/', methods=['GET'], endpoint=root),
@@ -1,11 +1,11 @@
1
1
  from starlette.middleware.base import BaseHTTPMiddleware
2
2
  from starlette.middleware.base import RequestResponseEndpoint
3
3
  from starlette.requests import Request
4
- from starlette.responses import JSONResponse
5
4
  from starlette.responses import Response
6
5
  from starlette.types import ASGIApp
7
6
 
8
7
  from core import logging
8
+ from core.api.api_response import KibaJSONResponse
9
9
  from core.exceptions import ClientException
10
10
  from core.exceptions import KibaException
11
11
  from core.exceptions import RedirectException
@@ -19,7 +19,7 @@ class ExceptionHandlingMiddleware(BaseHTTPMiddleware):
19
19
 
20
20
  @staticmethod
21
21
  def _convert_exception(exception: KibaException) -> Response:
22
- response = JSONResponse(status_code=exception.statusCode, content=exception.to_dict())
22
+ response = KibaJSONResponse(status_code=exception.statusCode, content=exception.to_dict())
23
23
  response.headers.update(exception.outgoing_headers())
24
24
  return response
25
25
 
@@ -14,6 +14,7 @@ from sqlalchemy.sql.selectable import TypedReturnsRows
14
14
 
15
15
  from core import logging
16
16
  from core.exceptions import InternalServerErrorException
17
+ from core.util import json_util
17
18
 
18
19
  DatabaseConnection = AsyncConnection
19
20
  ResultType = TypeVar('ResultType', bound=tuple) # type: ignore[type-arg]
@@ -43,6 +44,8 @@ class Database:
43
44
  self.connectionString,
44
45
  # echo_pool=True,
45
46
  # hide_parameters=False,
47
+ json_serializer=json_util.dumps,
48
+ json_deserializer=json_util.loads,
46
49
  pool_size=poolSize,
47
50
  pool_recycle=3600,
48
51
  pool_pre_ping=True,
@@ -0,0 +1,5 @@
1
+ from pydantic import JsonValue
2
+
3
+ type Json = JsonValue
4
+ type JsonObject = dict[str, JsonValue]
5
+ type JsonList = list[JsonValue]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev55
3
+ Version: 0.5.3.dev56
4
4
  Summary: Kiba Labs' python utilities
5
5
  Project-URL: repository, https://github.com/kibalabs/core-py
6
6
  Requires-Python: <4,>=3.12
@@ -78,11 +78,17 @@ kiba_core.egg-info/dependency_links.txt
78
78
  kiba_core.egg-info/requires.txt
79
79
  kiba_core.egg-info/top_level.txt
80
80
  tests/__init__.py
81
+ tests/test_database.py
82
+ tests/test_exceptions.py
83
+ tests/api/test_api_response.py
81
84
  tests/api/test_authorizer.py
85
+ tests/api/test_exception_handling_middleware.py
82
86
  tests/api/test_json_route.py
83
87
  tests/api/test_streaming_json_route.py
84
88
  tests/caching/test_dict_cache.py
85
89
  tests/caching/test_file_cache.py
90
+ tests/requester/__init__.py
91
+ tests/requester/test_requester.py
86
92
  tests/util/__init__.py
87
93
  tests/util/test_async_util.py
88
94
  tests/util/test_chain_util.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev55"
3
+ version = "0.5.3.dev56"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -0,0 +1,39 @@
1
+ import datetime
2
+
3
+ from starlette.applications import Starlette
4
+ from starlette.requests import Request
5
+ from starlette.routing import Route
6
+ from starlette.testclient import TestClient
7
+
8
+ from core.api.api_response import KibaJSONResponse
9
+ from core.api.default_routes import create_default_routes
10
+ from core.api.middleware.exception_handling_middleware import ExceptionHandlingMiddleware
11
+ from core.exceptions import BadRequestException
12
+
13
+
14
+ def test_kiba_json_response_uses_compact_utf8_serialization():
15
+ response = KibaJSONResponse(content={'message': 'Hello 🌍', 'createdAt': datetime.datetime(2025, 1, 2, 3, 4, 5), 'values': [1, 2]})
16
+
17
+ assert response.body == '{"message":"Hello 🌍","createdAt":"2025-01-02T03:04:05","values":[1,2]}'.encode()
18
+
19
+
20
+ def test_default_route_uses_kiba_json_response():
21
+ app = Starlette(routes=create_default_routes(name='core', version='1.0', environment='test'))
22
+ client = TestClient(app)
23
+
24
+ response = client.get('/')
25
+
26
+ assert response.content == b'{"server":"core","version":"1.0","environment":"test"}'
27
+
28
+
29
+ def test_exception_handling_middleware_uses_kiba_json_response():
30
+ async def failing_endpoint(_request: Request) -> KibaJSONResponse:
31
+ raise BadRequestException(message='bad input')
32
+
33
+ app = Starlette(routes=[Route('/fail', failing_endpoint)])
34
+ app.add_middleware(ExceptionHandlingMiddleware)
35
+ client = TestClient(app, raise_server_exceptions=False)
36
+
37
+ response = client.get('/fail')
38
+
39
+ assert response.content == b'{"exceptionType":"BadRequestException","message":"bad input","fields":{},"statusCode":400}'
@@ -0,0 +1,109 @@
1
+ import pytest
2
+ from pydantic import BaseModel
3
+ from starlette.applications import Starlette
4
+ from starlette.routing import Route
5
+ from starlette.testclient import TestClient
6
+
7
+ from core.api.api_request import KibaApiRequest
8
+ from core.api.json_route import json_route
9
+ from core.api.middleware.exception_handling_middleware import ExceptionHandlingMiddleware
10
+ from core.exceptions import BadRequestException
11
+ from core.exceptions import FoundRedirectException
12
+ from core.exceptions import MovedPermanentlyRedirectException
13
+ from core.exceptions import TooManyRequestsException
14
+
15
+
16
+ class SimpleRequest(BaseModel):
17
+ value: str
18
+
19
+
20
+ class SimpleResponse(BaseModel):
21
+ result: str
22
+
23
+
24
+ @pytest.fixture
25
+ def rate_limited_client():
26
+ @json_route(requestType=SimpleRequest, responseType=SimpleResponse)
27
+ async def limited_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
28
+ raise TooManyRequestsException(message='slow down', retryAfterSeconds=30)
29
+
30
+ app = Starlette(routes=[Route('/limited', limited_endpoint, methods=['POST'])])
31
+ app.add_middleware(ExceptionHandlingMiddleware)
32
+ return TestClient(app, raise_server_exceptions=False)
33
+
34
+
35
+ @pytest.fixture
36
+ def rate_limited_no_retry_client():
37
+ @json_route(requestType=SimpleRequest, responseType=SimpleResponse)
38
+ async def limited_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
39
+ raise TooManyRequestsException(message='slow down')
40
+
41
+ app = Starlette(routes=[Route('/limited', limited_endpoint, methods=['POST'])])
42
+ app.add_middleware(ExceptionHandlingMiddleware)
43
+ return TestClient(app, raise_server_exceptions=False)
44
+
45
+
46
+ @pytest.fixture
47
+ def found_redirect_client():
48
+ @json_route(requestType=SimpleRequest, responseType=SimpleResponse)
49
+ async def redirect_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
50
+ raise FoundRedirectException(location='https://example.com/elsewhere')
51
+
52
+ app = Starlette(routes=[Route('/redirect', redirect_endpoint, methods=['POST'])])
53
+ app.add_middleware(ExceptionHandlingMiddleware)
54
+ return TestClient(app, raise_server_exceptions=False, follow_redirects=False)
55
+
56
+
57
+ @pytest.fixture
58
+ def permanent_redirect_client():
59
+ @json_route(requestType=SimpleRequest, responseType=SimpleResponse)
60
+ async def redirect_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
61
+ raise MovedPermanentlyRedirectException(location='https://example.com/moved')
62
+
63
+ app = Starlette(routes=[Route('/redirect', redirect_endpoint, methods=['POST'])])
64
+ app.add_middleware(ExceptionHandlingMiddleware)
65
+ return TestClient(app, raise_server_exceptions=False, follow_redirects=False)
66
+
67
+
68
+ @pytest.fixture
69
+ def plain_error_client():
70
+ @json_route(requestType=SimpleRequest, responseType=SimpleResponse)
71
+ async def failing_endpoint(request: KibaApiRequest[SimpleRequest]) -> SimpleResponse:
72
+ raise BadRequestException(message='bad input')
73
+
74
+ app = Starlette(routes=[Route('/fail', failing_endpoint, methods=['POST'])])
75
+ app.add_middleware(ExceptionHandlingMiddleware)
76
+ return TestClient(app, raise_server_exceptions=False)
77
+
78
+
79
+ def test_too_many_requests_returns_429_with_retry_after_header(rate_limited_client):
80
+ response = rate_limited_client.post('/limited', json={'value': 'hello'})
81
+ assert response.status_code == 429
82
+ assert response.headers['retry-after'] == '30'
83
+
84
+
85
+ def test_too_many_requests_without_retry_after_omits_header(rate_limited_no_retry_client):
86
+ response = rate_limited_no_retry_client.post('/limited', json={'value': 'hello'})
87
+ assert response.status_code == 429
88
+ assert 'retry-after' not in response.headers
89
+
90
+
91
+ def test_found_redirect_sets_location_without_cache_header(found_redirect_client):
92
+ response = found_redirect_client.post('/redirect', json={'value': 'hello'})
93
+ assert response.status_code == 302
94
+ assert response.headers['location'] == 'https://example.com/elsewhere'
95
+ assert 'cache-control' not in response.headers
96
+
97
+
98
+ def test_permanent_redirect_sets_location_and_cache_header(permanent_redirect_client):
99
+ response = permanent_redirect_client.post('/redirect', json={'value': 'hello'})
100
+ assert response.status_code == 301
101
+ assert response.headers['location'] == 'https://example.com/moved'
102
+ assert response.headers['cache-control'] == f'max-age={60 * 60 * 24 * 365}'
103
+
104
+
105
+ def test_plain_exception_has_no_extra_headers(plain_error_client):
106
+ response = plain_error_client.post('/fail', json={'value': 'hello'})
107
+ assert response.status_code == 400
108
+ assert 'retry-after' not in response.headers
109
+ assert 'location' not in response.headers
@@ -0,0 +1,41 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from core.exceptions import NotFoundException
5
+ from core.exceptions import TooManyRequestsException
6
+ from core.requester.requester import Requester
7
+
8
+
9
+ class TestRequesterExceptionHeaders:
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_429_response_populates_retry_after_from_header(self):
13
+ def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
14
+ return httpx.Response(429, headers={'Retry-After': '17'}, text='Too Many Requests')
15
+
16
+ requester = Requester()
17
+ requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
18
+ with pytest.raises(TooManyRequestsException) as excInfo:
19
+ await requester.get(url='https://example.com/resource')
20
+ assert excInfo.value.retryAfterSeconds == 17
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_429_response_without_retry_after_header(self):
24
+ def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
25
+ return httpx.Response(429, text='Too Many Requests')
26
+
27
+ requester = Requester()
28
+ requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
29
+ with pytest.raises(TooManyRequestsException) as excInfo:
30
+ await requester.get(url='https://example.com/resource')
31
+ assert excInfo.value.retryAfterSeconds is None
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_404_response_still_maps_to_correct_exception_type(self):
35
+ def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001
36
+ return httpx.Response(404, text='Not Found')
37
+
38
+ requester = Requester()
39
+ requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
40
+ with pytest.raises(NotFoundException):
41
+ await requester.get(url='https://example.com/resource')
@@ -0,0 +1,121 @@
1
+ import datetime
2
+
3
+ import pytest
4
+ import sqlalchemy
5
+ from sqlalchemy import JSON
6
+ from sqlalchemy import Column
7
+ from sqlalchemy import MetaData
8
+ from sqlalchemy import Table
9
+ from sqlalchemy import insert
10
+ from sqlalchemy import select
11
+ from sqlalchemy import text
12
+
13
+ from core.exceptions import InternalServerErrorException
14
+ from core.store.database import Database
15
+ from core.util import json_util
16
+
17
+
18
+ @pytest.fixture
19
+ def database(tmp_path):
20
+ return Database(connectionString=Database.create_sqlite_connection_string(str(tmp_path / 'database.sqlite')))
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ ('engine', 'username', 'password', 'host', 'port', 'name', 'expected'),
25
+ [
26
+ ('postgresql+asyncpg', 'user', 'password', 'localhost', '5432', 'core', 'postgresql+asyncpg://user:password@localhost:5432/core'),
27
+ ],
28
+ )
29
+ def test_create_connection_string(engine, username, password, host, port, name, expected):
30
+ assert Database.create_connection_string(engine, username, password, host, port, name) == expected
31
+
32
+
33
+ def test_create_psql_connection_string():
34
+ assert Database.create_psql_connection_string('user', 'password', 'localhost', '5432', 'core') == 'postgresql+asyncpg://user:password@localhost:5432/core'
35
+
36
+
37
+ def test_create_sqlite_connection_string():
38
+ assert Database.create_sqlite_connection_string('database.sqlite') == 'sqlite+aiosqlite:///database.sqlite'
39
+
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_connect_is_idempotent_and_disconnect_clears_engine(database):
43
+ await database.connect(poolSize=1)
44
+ engine = database._engine
45
+
46
+ await database.connect(poolSize=2)
47
+
48
+ assert database._engine is engine
49
+ assert database._engine.sync_engine.dialect._json_serializer is json_util.dumps
50
+ assert database._engine.sync_engine.dialect._json_deserializer is json_util.loads
51
+
52
+ await database.disconnect()
53
+ assert database._engine is None
54
+ await database.disconnect()
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_create_transaction_requires_connection(database):
59
+ with pytest.raises(InternalServerErrorException, match='Engine has not been established'):
60
+ async with database.create_transaction():
61
+ pass
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_execute_requires_connection(database):
66
+ with pytest.raises(InternalServerErrorException, match='Connection has not been established'):
67
+ await database.execute(text('SELECT 1'))
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_json_values_round_trip_through_sqlalchemy(database):
72
+ metadata = MetaData()
73
+ records = Table(
74
+ 'records',
75
+ metadata,
76
+ Column('id', sqlalchemy.Integer, primary_key=True),
77
+ Column('payload', JSON, nullable=False),
78
+ )
79
+ payload = {
80
+ 'createdAt': datetime.datetime(2025, 1, 2, 3, 4, 5),
81
+ 'message': 'Hello 🌍',
82
+ 'nested': {'enabled': True, 'values': [None, 1, 2.5]},
83
+ }
84
+
85
+ await database.connect(poolSize=1)
86
+ async with database.create_transaction() as connection:
87
+ await connection.run_sync(metadata.create_all)
88
+ await database.execute(insert(records).values(payload=payload), connection=connection)
89
+ result = await database.execute(select(records.c.payload), connection=connection)
90
+
91
+ assert result.scalar_one() == {
92
+ 'createdAt': '2025-01-02T03:04:05',
93
+ 'message': 'Hello 🌍',
94
+ 'nested': {'enabled': True, 'values': [None, 1, 2.5]},
95
+ }
96
+ await database.disconnect()
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_context_connection_is_used_by_execute(database):
101
+ await database.connect(poolSize=1)
102
+
103
+ async with database.create_context_connection() as connection:
104
+ result = await database.execute(text('SELECT 42 AS answer'))
105
+ assert result.scalar_one() == 42
106
+ assert database._get_context_connection() is connection
107
+
108
+ assert database._get_context_connection() is None
109
+ await database.disconnect()
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_context_connection_cannot_be_nested(database):
114
+ await database.connect(poolSize=1)
115
+
116
+ with pytest.raises(InternalServerErrorException, match='Connection has already been established'):
117
+ async with database.create_context_connection():
118
+ async with database.create_context_connection():
119
+ pass
120
+
121
+ await database.disconnect()
@@ -0,0 +1,71 @@
1
+ import datetime
2
+ import email.utils
3
+
4
+ from core.exceptions import HTTP_EXCEPTIONS_MAP
5
+ from core.exceptions import BadRequestException
6
+ from core.exceptions import RedirectException
7
+ from core.exceptions import TooManyRequestsException
8
+
9
+
10
+ class TestExceptionHeaderHooks:
11
+
12
+ def test_default_from_headers_ignores_headers(self):
13
+ exception = BadRequestException.from_headers(message='bad request', statusCode=400, headers={'Retry-After': '99'})
14
+ assert exception.message == 'bad request'
15
+
16
+ def test_default_outgoing_headers_is_empty(self):
17
+ exception = BadRequestException(message='bad request')
18
+ assert exception.outgoing_headers() == {}
19
+
20
+
21
+ class TestTooManyRequestsException:
22
+
23
+ def test_from_headers_parses_delta_seconds(self):
24
+ exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': '42'})
25
+ assert exception.retryAfterSeconds == 42
26
+
27
+ def test_from_headers_parses_http_date(self):
28
+ futureDate = email.utils.format_datetime(datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=30))
29
+ exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': futureDate})
30
+ assert exception.retryAfterSeconds is not None
31
+ assert 25 <= exception.retryAfterSeconds <= 30
32
+
33
+ def test_from_headers_missing_header_is_none(self):
34
+ exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={})
35
+ assert exception.retryAfterSeconds is None
36
+
37
+ def test_from_headers_invalid_header_is_none(self):
38
+ exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': 'not-a-value'})
39
+ assert exception.retryAfterSeconds is None
40
+
41
+ def test_from_headers_past_http_date_clamps_to_zero(self):
42
+ pastDate = email.utils.format_datetime(datetime.datetime.now(datetime.UTC) - datetime.timedelta(seconds=30))
43
+ exception = TooManyRequestsException.from_headers(message='slow down', statusCode=429, headers={'Retry-After': pastDate})
44
+ assert exception.retryAfterSeconds == 0
45
+
46
+ def test_outgoing_headers_includes_retry_after_when_set(self):
47
+ exception = TooManyRequestsException(message='slow down', retryAfterSeconds=42)
48
+ assert exception.outgoing_headers() == {'Retry-After': '42'}
49
+
50
+ def test_outgoing_headers_empty_when_not_set(self):
51
+ exception = TooManyRequestsException(message='slow down')
52
+ assert exception.outgoing_headers() == {}
53
+
54
+ def test_dispatched_via_http_exceptions_map(self):
55
+ exceptionCls = HTTP_EXCEPTIONS_MAP[429]
56
+ exception = exceptionCls.from_headers(message='slow down', statusCode=429, headers={'Retry-After': '7'})
57
+ assert isinstance(exception, TooManyRequestsException)
58
+ assert exception.retryAfterSeconds == 7
59
+
60
+
61
+ class TestRedirectException:
62
+
63
+ def test_outgoing_headers_without_cache_header(self):
64
+ exception = RedirectException(location='https://example.com', statusCode=302, shouldAddCacheHeader=False)
65
+ assert exception.outgoing_headers() == {'Location': 'https://example.com'}
66
+
67
+ def test_outgoing_headers_with_cache_header(self):
68
+ exception = RedirectException(location='https://example.com', statusCode=301, shouldAddCacheHeader=True)
69
+ headers = exception.outgoing_headers()
70
+ assert headers['Location'] == 'https://example.com'
71
+ assert headers['Cache-Control'] == f'max-age={60 * 60 * 24 * 365}'
File without changes
@@ -1,7 +0,0 @@
1
- from collections.abc import Mapping
2
- from collections.abc import Sequence
3
-
4
- JsonBaseType = str | int | float | bool | None
5
- type Json = dict[str, 'Json'] | Mapping[str, 'Json'] | list['Json'] | Sequence['Json'] | JsonBaseType
6
- type JsonObject = dict[str, 'Json']
7
- type JsonList = list['Json']
File without changes