kiba-core 0.5.3.dev88__tar.gz → 0.5.3.dev90__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 (146) hide show
  1. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/CHANGELOG.md +4 -0
  2. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/database_connection_middleware.py +11 -1
  4. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/request_body.py +6 -5
  5. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/store/database.py +28 -5
  6. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/kiba_core.egg-info/PKG-INFO +1 -1
  7. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/kiba_core.egg-info/SOURCES.txt +1 -0
  8. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/pyproject.toml +1 -1
  9. kiba_core-0.5.3.dev90/tests/api/test_database_connection_middleware.py +168 -0
  10. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_json_route.py +19 -0
  11. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/test_database.py +55 -0
  12. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.github/pull_request_template.md +0 -0
  13. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.github/workflows/deploy.yml +0 -0
  14. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.github/workflows/publish.yml +0 -0
  15. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.github/workflows/pull-request.yml +0 -0
  16. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.github/workflows/release.yml +0 -0
  17. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/.gitignore +0 -0
  18. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/Dockerfile +0 -0
  19. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/README.md +0 -0
  20. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/__init__.py +0 -0
  21. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/__init__.py +0 -0
  22. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/api_request.py +0 -0
  23. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/api_response.py +0 -0
  24. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/authorizer.py +0 -0
  25. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/default_routes.py +0 -0
  26. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/health.py +0 -0
  27. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/json_route.py +0 -0
  28. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/__init__.py +0 -0
  29. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/exception_handling_middleware.py +0 -0
  30. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/logging_middleware.py +0 -0
  31. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/origin_ip_middleware.py +0 -0
  32. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/middleware/server_headers_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/openapi.py +0 -0
  34. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/rate_limit.py +0 -0
  35. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/request_context.py +0 -0
  36. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/route.py +0 -0
  37. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/route_auth.py +0 -0
  38. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/route_metadata.py +0 -0
  39. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/streaming_json_route.py +0 -0
  40. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/api/uploaded_file.py +0 -0
  41. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/aws_requester.py +0 -0
  42. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/caching/__init__.py +0 -0
  43. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/caching/cache.py +0 -0
  44. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/caching/dict_cache.py +0 -0
  45. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/caching/file_cache.py +0 -0
  46. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/cron/__init__.py +0 -0
  47. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/cron/api_request_cron_job_processor.py +0 -0
  48. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/cron/cron_executor.py +0 -0
  49. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/cron/cron_job_processor.py +0 -0
  50. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/cron/model.py +0 -0
  51. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/exceptions.py +0 -0
  52. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/http/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/http/basic_authentication.py +0 -0
  54. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/http/jwt.py +0 -0
  55. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/http/rest_method.py +0 -0
  56. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/locks/__init__.py +0 -0
  57. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/locks/lock.py +0 -0
  58. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/locks/model.py +0 -0
  59. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/locks/sql.py +0 -0
  60. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/logging.py +0 -0
  61. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/notifications/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/notifications/discord_client.py +0 -0
  63. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/notifications/notification_client.py +0 -0
  64. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/notifications/slack_client.py +0 -0
  65. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/py.typed +0 -0
  66. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/__init__.py +0 -0
  67. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/aqs.py +0 -0
  68. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/cosmos.py +0 -0
  69. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/message_queue.py +0 -0
  70. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/message_queue_processor.py +0 -0
  71. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/model.py +0 -0
  72. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/sql.py +0 -0
  73. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/queues/sqs.py +0 -0
  74. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/requester/__init__.py +0 -0
  75. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/requester/requester.py +0 -0
  76. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/s3_manager.py +0 -0
  77. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/service_client.py +0 -0
  78. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/store/__init__.py +0 -0
  79. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/store/entity_repository.py +0 -0
  80. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/store/retriever.py +0 -0
  81. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/store/saver.py +0 -0
  82. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/__init__.py +0 -0
  83. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/async_util.py +0 -0
  84. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/chain_util.py +0 -0
  85. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/date_util.py +0 -0
  86. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/dict_util.py +0 -0
  87. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/file_util.py +0 -0
  88. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/hashing_util.py +0 -0
  89. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/http_util.py +0 -0
  90. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/json_util.py +0 -0
  91. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/list_util.py +0 -0
  92. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/process_util.py +0 -0
  93. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/string_util.py +0 -0
  94. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/typing_util.py +0 -0
  95. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/url_util.py +0 -0
  96. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/util/value_holder.py +0 -0
  97. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/web3/__init__.py +0 -0
  98. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/web3/eth_client.py +0 -0
  99. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/web3/eth_client_registry.py +0 -0
  100. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/core/web3/multicall3.py +0 -0
  101. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/kiba_core.egg-info/dependency_links.txt +0 -0
  102. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/kiba_core.egg-info/requires.txt +0 -0
  103. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/kiba_core.egg-info/top_level.txt +0 -0
  104. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/makefile +0 -0
  105. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/setup.cfg +0 -0
  106. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/__init__.py +0 -0
  107. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_api_response.py +0 -0
  108. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_authorizer.py +0 -0
  109. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_exception_handling_middleware.py +0 -0
  110. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_openapi.py +0 -0
  111. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_origin_ip_middleware.py +0 -0
  112. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_rate_limit.py +0 -0
  113. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_request_context.py +0 -0
  114. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_route.py +0 -0
  115. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/api/test_streaming_json_route.py +0 -0
  116. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/caching/test_dict_cache.py +0 -0
  117. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/caching/test_file_cache.py +0 -0
  118. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/cron/__init__.py +0 -0
  119. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/cron/test_cron_executor.py +0 -0
  120. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/locks/test_sql_lock.py +0 -0
  121. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/conftest.py +0 -0
  122. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/test_aqs_queue.py +0 -0
  123. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/test_cosmos_queue.py +0 -0
  124. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/test_message_queue_processor.py +0 -0
  125. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/test_sql_queue.py +0 -0
  126. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/queues/test_sqs_queue.py +0 -0
  127. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/requester/__init__.py +0 -0
  128. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/requester/test_requester.py +0 -0
  129. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/test_entity_repository.py +0 -0
  130. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/test_exceptions.py +0 -0
  131. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/test_logging.py +0 -0
  132. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/__init__.py +0 -0
  133. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_async_util.py +0 -0
  134. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_chain_util.py +0 -0
  135. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_date_util.py +0 -0
  136. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_dict_util.py +0 -0
  137. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_file_util.py +0 -0
  138. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_json_util.py +0 -0
  139. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_list_util.py +0 -0
  140. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_process_util.py +0 -0
  141. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_string_util.py +0 -0
  142. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/util/test_url_util.py +0 -0
  143. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/web3/__init__.py +0 -0
  144. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/web3/test_eth_client_registry.py +0 -0
  145. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/tests/web3/test_rest_eth_client.py +0 -0
  146. {kiba_core-0.5.3.dev88 → kiba_core-0.5.3.dev90}/uv.lock +0 -0
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [PATCH] Fixed `DatabaseConnectionMiddleware`'s commit when the response starts failing ("another operation is in progress") for streaming responses behind a `BaseHTTPMiddleware`: `Database.execute` and `Database.commit_context_connection` now wait for each other on the same connection
12
+ - [MINOR] Added `Database.commit_context_connection` to commit the context connection's writes so far; anything run on it afterwards is committed when the context connection ends
13
+ - [PATCH] Updated `DatabaseConnectionMiddleware` to commit before the response starts, so a client can't get the response and make its next request before the request's writes are visible (writes made while streaming are still committed when the request ends)
14
+ - [PATCH] Fixed `json_route` and `streaming_json_route` leaving the temporary files of multipart uploads open
11
15
  - [MINOR] Updated `json_route` and `streaming_json_route` (and so `route`) to accept `multipart/form-data` bodies: form fields are used like JSON body fields and uploaded files are passed as `core.api.uploaded_file.UploadedFile` (`filename`, `contentType`, `content`), so request models can declare `file: UploadedFile`. A field given more than once raises `BadRequestException`. The `core-api` extra now installs `python-multipart`
12
16
  - [MAJOR] Added abstract `MessageQueue.release_message` to hand a claimed message back to be claimed again straight away without using an attempt (`SqlMessageQueue` and `CosmosMessageQueue` keep `postCount`; `SqsMessageQueue` and `AqsMessageQueue` make the message visible again, though SQS still counts the next receive towards its redrive policy)
13
17
  - [MAJOR] Added abstract `MessageQueue.delete_expired_messages`: `SqlMessageQueue` deletes `succeeded` and `deduplicated` messages older than `retentionSeconds` (the other queues remove messages on completion and return 0). `SqlMessageQueue.get_message_count` no longer deletes them and its `shouldSkipRemovingNonRetained` parameter is removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev88
3
+ Version: 0.5.3.dev90
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,4 +1,5 @@
1
1
  from starlette.types import ASGIApp
2
+ from starlette.types import Message
2
3
  from starlette.types import Receive
3
4
  from starlette.types import Scope
4
5
  from starlette.types import Send
@@ -18,4 +19,13 @@ class DatabaseConnectionMiddleware:
18
19
  await self.app(scope, receive, send)
19
20
  return
20
21
  async with self.database.create_context_connection():
21
- await self.app(scope, receive, send)
22
+
23
+ # NOTE(krishan711): commit before the response starts, otherwise a client can get the response and make its
24
+ # next request before this request's writes are visible. Anything after this (e.g. while streaming) is
25
+ # committed when the context connection ends
26
+ async def send_after_commit(message: Message) -> None:
27
+ if message['type'] == 'http.response.start':
28
+ await self.database.commit_context_connection()
29
+ await send(message)
30
+
31
+ await self.app(scope, receive, send_after_commit)
@@ -12,12 +12,13 @@ from core.util.typing_util import JsonObject
12
12
 
13
13
  async def read_request_body(request: Request) -> Mapping[str, object]:
14
14
  if request.headers.get('Content-Type', '').startswith('multipart/form-data'):
15
- form = await request.form()
16
15
  body: dict[str, object] = {}
17
- for key, value in form.multi_items():
18
- if key in body:
19
- raise BadRequestException(f'Invalid form body: {key} is given more than once')
20
- body[key] = UploadedFile(filename=value.filename, contentType=value.content_type, content=await value.read()) if isinstance(value, UploadFile) else value
16
+ # NOTE(krishan711): the form is closed on exit so uploaded files' temporary files are closed (their content has been read by then)
17
+ async with request.form() as form:
18
+ for key, value in form.multi_items():
19
+ if key in body:
20
+ raise BadRequestException(f'Invalid form body: {key} is given more than once')
21
+ body[key] = UploadedFile(filename=value.filename, contentType=value.content_type, content=await value.read()) if isinstance(value, UploadFile) else value
21
22
  return body
22
23
  bodyBytes = await request.body()
23
24
  if len(bodyBytes) == 0:
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import contextlib
2
3
  import contextvars
3
4
  import datetime
@@ -47,6 +48,7 @@ class Database:
47
48
  self._engine: AsyncEngine | None = None
48
49
  self._connectionContext = contextvars.ContextVar[DatabaseConnection | None]('_connectionContext')
49
50
  self._connectionsWithWrites = weakref.WeakSet[DatabaseConnection]()
51
+ self._connectionLocks = weakref.WeakKeyDictionary[DatabaseConnection, asyncio.Lock]()
50
52
 
51
53
  async def connect(self, poolSize: int = 100) -> None:
52
54
  if not self._engine:
@@ -86,6 +88,16 @@ class Database:
86
88
  async with self._engine.begin() as connection:
87
89
  yield connection
88
90
 
91
+ # NOTE(krishan711): a connection can only run one operation at a time, so execute and commit_context_connection wait
92
+ # for each other. e.g. DatabaseConnectionMiddleware commits when the response starts, which (behind a BaseHTTPMiddleware)
93
+ # can be while the route, in another task, is still running queries for a streaming response
94
+ def _get_connection_lock(self, connection: DatabaseConnection) -> asyncio.Lock:
95
+ lock = self._connectionLocks.get(connection)
96
+ if lock is None:
97
+ lock = asyncio.Lock()
98
+ self._connectionLocks[connection] = lock
99
+ return lock
100
+
89
101
  def _get_context_connection(self) -> DatabaseConnection | None:
90
102
  try:
91
103
  connection = self._connectionContext.get()
@@ -107,12 +119,12 @@ class Database:
107
119
  raise InternalServerErrorException(message='Engine has not been established. Please called collect() first.')
108
120
  if self._get_context_connection() is not None:
109
121
  raise InternalServerErrorException(message='Connection has already been established in this context.')
110
- connection = None
111
122
  try:
112
- async with self._engine.begin() as connection:
123
+ async with self._engine.connect() as connection:
113
124
  self._connectionContext.set(connection)
114
125
  try:
115
126
  yield connection
127
+ await self.commit_context_connection()
116
128
  finally:
117
129
  self._connectionContext.set(None)
118
130
  except sqlalchemy.exc.InterfaceError as exception:
@@ -122,6 +134,16 @@ class Database:
122
134
  await self.disconnect()
123
135
  await self.connect()
124
136
 
137
+ # NOTE(krishan711): commits what the context connection has done so far. Anything run on it afterwards is in a new
138
+ # transaction, which is committed when the context connection ends (or rolled back if it ends with an exception).
139
+ async def commit_context_connection(self) -> None:
140
+ connection = self._get_context_connection()
141
+ if not connection:
142
+ raise InternalServerErrorException(message='No connection found. Please call create_context_connection() for the context.')
143
+ async with self._get_connection_lock(connection=connection):
144
+ await connection.commit()
145
+ self._connectionsWithWrites.discard(connection)
146
+
125
147
  # NOTE(krishan711): unlike create_context_connection this may be used inside an existing context connection.
126
148
  # Everything in the block runs on a new transaction that commits when the block exits, independent of the
127
149
  # outer transaction, which becomes the context connection again afterwards. It cannot see the outer
@@ -155,6 +177,7 @@ class Database:
155
177
  connection = self._get_context_connection()
156
178
  if not connection:
157
179
  raise InternalServerErrorException(message='No connection found. Please provide a connection or call create_context_connection() for the context.')
158
- if isinstance(query, UpdateBase):
159
- self._connectionsWithWrites.add(connection)
160
- return typing.cast(Result[*tuple[typing.Any, ...]], await connection.execute(statement=query)) # type: ignore[explicit-any]
180
+ async with self._get_connection_lock(connection=connection):
181
+ if isinstance(query, UpdateBase):
182
+ self._connectionsWithWrites.add(connection)
183
+ return typing.cast(Result[*tuple[typing.Any, ...]], await connection.execute(statement=query)) # type: ignore[explicit-any]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev88
3
+ Version: 0.5.3.dev90
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
@@ -106,6 +106,7 @@ tests/test_exceptions.py
106
106
  tests/test_logging.py
107
107
  tests/api/test_api_response.py
108
108
  tests/api/test_authorizer.py
109
+ tests/api/test_database_connection_middleware.py
109
110
  tests/api/test_exception_handling_middleware.py
110
111
  tests/api/test_json_route.py
111
112
  tests/api/test_openapi.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev88"
3
+ version = "0.5.3.dev90"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -0,0 +1,168 @@
1
+ import asyncio
2
+ import os
3
+ import uuid
4
+ from collections.abc import AsyncIterator
5
+
6
+ import pytest
7
+ import pytest_asyncio
8
+ import sqlalchemy
9
+ from sqlalchemy import Column
10
+ from sqlalchemy import MetaData
11
+ from sqlalchemy import Table
12
+ from sqlalchemy import insert
13
+ from sqlalchemy import select
14
+ from sqlalchemy import text
15
+ from starlette.applications import Starlette
16
+ from starlette.middleware import Middleware
17
+ from starlette.middleware.base import BaseHTTPMiddleware
18
+ from starlette.requests import Request
19
+ from starlette.responses import StreamingResponse
20
+ from starlette.routing import Route
21
+
22
+ from core.api.middleware.database_connection_middleware import DatabaseConnectionMiddleware
23
+ from core.store.database import Database
24
+
25
+ PSQL_CONNECTION_STRING = os.environ.get('CORE_TEST_PSQL_CONNECTION_STRING')
26
+
27
+ metadata = MetaData()
28
+ records = Table(f'records_{uuid.uuid4().hex}', metadata, Column('name', sqlalchemy.Text, primary_key=True))
29
+
30
+
31
+ @pytest_asyncio.fixture(params=['sqlite', 'postgresql'] if PSQL_CONNECTION_STRING else ['sqlite'])
32
+ async def database(request, tmp_path):
33
+ connectionString = PSQL_CONNECTION_STRING if request.param == 'postgresql' else Database.create_sqlite_connection_string(str(tmp_path / 'database.sqlite'))
34
+ database = Database(connectionString=connectionString)
35
+ await database.connect(poolSize=2)
36
+ async with database.create_transaction() as connection:
37
+ await connection.run_sync(metadata.drop_all)
38
+ await connection.run_sync(metadata.create_all)
39
+ yield database
40
+ async with database.create_transaction() as connection:
41
+ await connection.run_sync(metadata.drop_all)
42
+ await database.disconnect()
43
+
44
+
45
+ async def get_record_names(database: Database) -> list[str]:
46
+ async with database.create_transaction() as connection:
47
+ result = await database.execute(select(records.c.name).order_by(records.c.name), connection=connection)
48
+ return list(result.scalars().all())
49
+
50
+
51
+ async def call_app(app, database: Database) -> list[tuple[str, list[str]]]:
52
+ sentMessages: list[tuple[str, list[str]]] = []
53
+
54
+ async def receive():
55
+ return {'type': 'http.request', 'body': b'', 'more_body': False}
56
+
57
+ async def send(message):
58
+ sentMessages.append((message['type'], await get_record_names(database=database)))
59
+
60
+ await DatabaseConnectionMiddleware(app=app, database=database)({'type': 'http', 'method': 'POST', 'path': '/'}, receive, send)
61
+ return sentMessages
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_writes_are_committed_before_the_response_starts(database):
66
+ async def app(scope, receive, send):
67
+ await database.execute(insert(records).values(name='request'))
68
+ await send({'type': 'http.response.start', 'status': 200, 'headers': []})
69
+ await send({'type': 'http.response.body', 'body': b'ok'})
70
+
71
+ sentMessages = await call_app(app=app, database=database)
72
+
73
+ assert sentMessages == [('http.response.start', ['request']), ('http.response.body', ['request'])]
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_writes_while_streaming_are_committed_when_the_request_ends(database):
78
+ async def app(scope, receive, send):
79
+ await database.execute(insert(records).values(name='before'))
80
+ await send({'type': 'http.response.start', 'status': 200, 'headers': []})
81
+ await database.execute(insert(records).values(name='streaming'))
82
+ await send({'type': 'http.response.body', 'body': b'ok'})
83
+
84
+ sentMessages = await call_app(app=app, database=database)
85
+
86
+ assert sentMessages == [('http.response.start', ['before']), ('http.response.body', ['before'])]
87
+ assert await get_record_names(database=database) == ['before', 'streaming']
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_writes_are_rolled_back_when_the_request_fails_before_responding(database):
92
+ async def app(scope, receive, send):
93
+ await database.execute(insert(records).values(name='request'))
94
+ raise ValueError('failed')
95
+
96
+ with pytest.raises(ValueError, match='failed'):
97
+ await call_app(app=app, database=database)
98
+
99
+ assert await get_record_names(database=database) == []
100
+
101
+
102
+ async def post_to_app(app) -> tuple[int, bytes]:
103
+ scope = {
104
+ 'type': 'http', 'asgi': {'version': '3.0'}, 'http_version': '1.1', 'method': 'POST', 'scheme': 'http', 'path': '/', 'raw_path': b'/',
105
+ 'root_path': '', 'query_string': b'', 'headers': [], 'client': ('127.0.0.1', 1), 'server': ('test', 80),
106
+ }
107
+ isRequestSent = False
108
+ responseFinished = asyncio.Event()
109
+ statusHolder: list[int] = []
110
+ bodyParts: list[bytes] = []
111
+
112
+ async def receive():
113
+ nonlocal isRequestSent
114
+ if not isRequestSent:
115
+ isRequestSent = True
116
+ return {'type': 'http.request', 'body': b'', 'more_body': False}
117
+ await responseFinished.wait()
118
+ return {'type': 'http.disconnect'}
119
+
120
+ async def send(message):
121
+ if message['type'] == 'http.response.start':
122
+ statusHolder.append(message['status'])
123
+ elif message['type'] == 'http.response.body':
124
+ bodyParts.append(message.get('body', b''))
125
+ if not message.get('more_body', False):
126
+ responseFinished.set()
127
+
128
+ await app(scope, receive, send)
129
+ responseFinished.set()
130
+ return (statusHolder or [0])[0], b''.join(bodyParts)
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_streaming_through_base_http_middleware_does_not_query_the_connection_concurrently(database):
135
+ # NOTE(krishan711): BaseHTTPMiddleware runs the route in another task, so the stream is still running queries when
136
+ # the response start reaches DatabaseConnectionMiddleware and it commits
137
+ async def stream() -> AsyncIterator[bytes]:
138
+ for index in range(20):
139
+ if database.get_dialect_name() == 'postgresql':
140
+ await database.execute(text('SELECT pg_sleep(0.005)'))
141
+ await database.execute(insert(records).values(name=f'event-{index:02d}'))
142
+ yield b'event\n'
143
+
144
+ async def endpoint(request: Request) -> StreamingResponse:
145
+ await database.execute(insert(records).values(name='request'))
146
+ return StreamingResponse(stream())
147
+
148
+ async def passthrough(request, callNext):
149
+ return await callNext(request)
150
+
151
+ app = Starlette(
152
+ routes=[Route('/', endpoint=endpoint, methods=['POST'])],
153
+ middleware=[
154
+ Middleware(DatabaseConnectionMiddleware, database=database),
155
+ Middleware(BaseHTTPMiddleware, dispatch=passthrough),
156
+ ],
157
+ )
158
+ for _ in range(5):
159
+ status, body = await post_to_app(app=app)
160
+ assert status == 200
161
+ assert body == b'event\n' * 20
162
+ assert len(await get_record_names(database=database)) == 21
163
+ async with database.create_transaction() as connection:
164
+ await connection.execute(records.delete())
165
+
166
+ async with database.create_transaction() as connection:
167
+ await connection.execute(insert(records).values(name='check'))
168
+ assert await get_record_names(database=database) == ['check']
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
  from starlette.applications import Starlette
3
3
  from starlette.routing import Route
4
+ from starlette.datastructures import UploadFile
4
5
  from starlette.testclient import TestClient
5
6
  from pydantic import BaseModel
6
7
 
@@ -189,3 +190,21 @@ def test_json_route_with_multipart_body_rejects_repeated_fields(client):
189
190
  )
190
191
  assert response.status_code == 400
191
192
  assert "file is given more than once" in response.json().get("message", "")
193
+
194
+
195
+ def test_json_route_with_multipart_body_closes_uploaded_files(client, monkeypatch):
196
+ closedFilenames = []
197
+ originalClose = UploadFile.close
198
+
199
+ async def close(self):
200
+ closedFilenames.append(self.filename)
201
+ await originalClose(self)
202
+
203
+ monkeypatch.setattr(UploadFile, "close", close)
204
+ response = client.post(
205
+ "/upload",
206
+ files={"file": ("scan.dcm", b"file-content", "application/dicom")},
207
+ data={"age": "41"},
208
+ )
209
+ assert response.status_code == 200
210
+ assert closedFilenames == ["scan.dcm"]
@@ -229,3 +229,58 @@ async def test_isolated_context_connection_raises_on_uncommitted_outer_writes_wh
229
229
  async with database.create_isolated_context_connection():
230
230
  pass
231
231
  await database.disconnect()
232
+
233
+
234
+ @pytest.mark.asyncio
235
+ async def test_commit_context_connection_makes_writes_visible_before_the_context_ends(database):
236
+ metadata = MetaData()
237
+ records = Table('records', metadata, Column('name', sqlalchemy.Text, primary_key=True))
238
+ await database.connect(poolSize=2)
239
+ async with database.create_transaction() as connection:
240
+ await connection.run_sync(metadata.create_all)
241
+
242
+ with pytest.raises(ValueError, match='rollback'):
243
+ async with database.create_context_connection():
244
+ await database.execute(insert(records).values(name='committed'))
245
+ await database.commit_context_connection()
246
+ async with database.create_transaction() as connection:
247
+ result = await database.execute(select(records.c.name), connection=connection)
248
+ assert result.scalars().all() == ['committed']
249
+ await database.execute(insert(records).values(name='rolledback'))
250
+ raise ValueError('rollback')
251
+
252
+ async with database.create_transaction() as connection:
253
+ result = await database.execute(select(records.c.name), connection=connection)
254
+ assert result.scalars().all() == ['committed']
255
+ await database.disconnect()
256
+
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_writes_after_commit_context_connection_are_committed_when_the_context_ends(database, caplog):
260
+ metadata = MetaData()
261
+ records = Table('records', metadata, Column('name', sqlalchemy.Text, primary_key=True))
262
+ await database.connect(poolSize=2)
263
+ async with database.create_transaction() as connection:
264
+ await connection.run_sync(metadata.create_all)
265
+
266
+ with caplog.at_level(logging.ERROR):
267
+ async with database.create_context_connection():
268
+ await database.execute(insert(records).values(name='first'))
269
+ await database.commit_context_connection()
270
+ async with database.create_isolated_context_connection():
271
+ pass
272
+ await database.execute(insert(records).values(name='second'))
273
+ assert 'ISOLATED_CONNECTION_AFTER_UNCOMMITTED_WRITES' not in caplog.text
274
+
275
+ async with database.create_transaction() as connection:
276
+ result = await database.execute(select(records.c.name).order_by(records.c.name), connection=connection)
277
+ assert result.scalars().all() == ['first', 'second']
278
+ await database.disconnect()
279
+
280
+
281
+ @pytest.mark.asyncio
282
+ async def test_commit_context_connection_requires_a_context_connection(database):
283
+ await database.connect(poolSize=1)
284
+ with pytest.raises(InternalServerErrorException, match='No connection found'):
285
+ await database.commit_context_connection()
286
+ await database.disconnect()
File without changes