kiba-core 0.5.3.dev85__tar.gz → 0.5.3.dev87__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 (145) hide show
  1. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/CHANGELOG.md +5 -0
  2. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/PKG-INFO +2 -1
  3. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/json_route.py +2 -12
  4. kiba_core-0.5.3.dev87/core/api/request_body.py +31 -0
  5. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/streaming_json_route.py +2 -11
  6. kiba_core-0.5.3.dev87/core/api/uploaded_file.py +7 -0
  7. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/aqs.py +8 -0
  8. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/cosmos.py +21 -0
  9. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/message_queue.py +9 -0
  10. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/message_queue_processor.py +88 -10
  11. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/sql.py +23 -13
  12. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/sqs.py +8 -0
  13. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/PKG-INFO +2 -1
  14. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/SOURCES.txt +2 -0
  15. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/requires.txt +1 -0
  16. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/pyproject.toml +2 -1
  17. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_json_route.py +44 -0
  18. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_aqs_queue.py +7 -0
  19. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_cosmos_queue.py +9 -0
  20. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_sql_queue.py +84 -3
  21. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_sqs_queue.py +7 -0
  22. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/uv.lock +11 -0
  23. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/pull_request_template.md +0 -0
  24. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/deploy.yml +0 -0
  25. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/publish.yml +0 -0
  26. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/pull-request.yml +0 -0
  27. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/release.yml +0 -0
  28. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.gitignore +0 -0
  29. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/Dockerfile +0 -0
  30. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/README.md +0 -0
  31. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/__init__.py +0 -0
  32. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/__init__.py +0 -0
  33. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/api_request.py +0 -0
  34. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/api_response.py +0 -0
  35. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/authorizer.py +0 -0
  36. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/default_routes.py +0 -0
  37. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/health.py +0 -0
  38. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/__init__.py +0 -0
  39. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/database_connection_middleware.py +0 -0
  40. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/exception_handling_middleware.py +0 -0
  41. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/logging_middleware.py +0 -0
  42. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/origin_ip_middleware.py +0 -0
  43. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/server_headers_middleware.py +0 -0
  44. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/openapi.py +0 -0
  45. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/rate_limit.py +0 -0
  46. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/request_context.py +0 -0
  47. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route.py +0 -0
  48. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route_auth.py +0 -0
  49. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route_metadata.py +0 -0
  50. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/aws_requester.py +0 -0
  51. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/__init__.py +0 -0
  52. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/cache.py +0 -0
  53. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/dict_cache.py +0 -0
  54. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/file_cache.py +0 -0
  55. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/api_request_cron_job_processor.py +0 -0
  57. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/cron_executor.py +0 -0
  58. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/cron_job_processor.py +0 -0
  59. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/model.py +0 -0
  60. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/exceptions.py +0 -0
  61. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/basic_authentication.py +0 -0
  63. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/jwt.py +0 -0
  64. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/rest_method.py +0 -0
  65. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/__init__.py +0 -0
  66. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/lock.py +0 -0
  67. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/model.py +0 -0
  68. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/sql.py +0 -0
  69. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/logging.py +0 -0
  70. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/__init__.py +0 -0
  71. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/discord_client.py +0 -0
  72. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/notification_client.py +0 -0
  73. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/slack_client.py +0 -0
  74. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/py.typed +0 -0
  75. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/__init__.py +0 -0
  76. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/model.py +0 -0
  77. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/requester/__init__.py +0 -0
  78. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/requester/requester.py +0 -0
  79. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/s3_manager.py +0 -0
  80. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/service_client.py +0 -0
  81. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/__init__.py +0 -0
  82. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/database.py +0 -0
  83. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/entity_repository.py +0 -0
  84. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/retriever.py +0 -0
  85. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/saver.py +0 -0
  86. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/__init__.py +0 -0
  87. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/async_util.py +0 -0
  88. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/chain_util.py +0 -0
  89. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/date_util.py +0 -0
  90. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/dict_util.py +0 -0
  91. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/file_util.py +0 -0
  92. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/hashing_util.py +0 -0
  93. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/http_util.py +0 -0
  94. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/json_util.py +0 -0
  95. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/list_util.py +0 -0
  96. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/process_util.py +0 -0
  97. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/string_util.py +0 -0
  98. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/typing_util.py +0 -0
  99. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/url_util.py +0 -0
  100. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/value_holder.py +0 -0
  101. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/__init__.py +0 -0
  102. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/eth_client.py +0 -0
  103. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/eth_client_registry.py +0 -0
  104. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/multicall3.py +0 -0
  105. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/dependency_links.txt +0 -0
  106. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/top_level.txt +0 -0
  107. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/makefile +0 -0
  108. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/setup.cfg +0 -0
  109. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/__init__.py +0 -0
  110. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_api_response.py +0 -0
  111. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_authorizer.py +0 -0
  112. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_exception_handling_middleware.py +0 -0
  113. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_openapi.py +0 -0
  114. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_origin_ip_middleware.py +0 -0
  115. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_rate_limit.py +0 -0
  116. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_request_context.py +0 -0
  117. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_route.py +0 -0
  118. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_streaming_json_route.py +0 -0
  119. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/caching/test_dict_cache.py +0 -0
  120. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/caching/test_file_cache.py +0 -0
  121. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/cron/__init__.py +0 -0
  122. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/cron/test_cron_executor.py +0 -0
  123. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/locks/test_sql_lock.py +0 -0
  124. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/conftest.py +0 -0
  125. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_message_queue_processor.py +0 -0
  126. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/requester/__init__.py +0 -0
  127. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/requester/test_requester.py +0 -0
  128. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_database.py +0 -0
  129. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_entity_repository.py +0 -0
  130. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_exceptions.py +0 -0
  131. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_logging.py +0 -0
  132. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/__init__.py +0 -0
  133. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_async_util.py +0 -0
  134. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_chain_util.py +0 -0
  135. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_date_util.py +0 -0
  136. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_dict_util.py +0 -0
  137. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_file_util.py +0 -0
  138. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_json_util.py +0 -0
  139. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_list_util.py +0 -0
  140. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_process_util.py +0 -0
  141. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_string_util.py +0 -0
  142. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_url_util.py +0 -0
  143. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/web3/__init__.py +0 -0
  144. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/web3/test_eth_client_registry.py +0 -0
  145. {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/web3/test_rest_eth_client.py +0 -0
@@ -8,6 +8,11 @@ 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] 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
+ - [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
+ - [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
14
+ - [MINOR] Updated `MessageQueueProcessor` to shut down gracefully: `run`/`run_batches` handle SIGTERM and SIGINT (a second signal falls back to the default handler), as does `request_shutdown()`, by finishing the message being processed, releasing claimed messages not yet started and stopping instead of sleeping. A processed message whose task is cancelled is released rather than left until its lease expires
15
+ - [MINOR] Added `deleteExpiredMessagesIntervalSeconds` (default 600) to `MessageQueueProcessor`, which calls `delete_expired_messages` at most this often while running, logging rather than raising on failure; added `shouldProcessInParallel` to `run_batches`
11
16
  - [MINOR] Added optional `proxyUrl` to `Requester` to send all its requests through an HTTP(S) proxy, e.g. to reach an API that allowlists a fixed egress IP
12
17
  - [MINOR] Added optional `timeoutSeconds` (default 10) to `RestEthClient` to set the timeout of each request, e.g. a shorter one for an archive node so a degraded node fails fast
13
18
  - [MINOR] Added `UNDEFINED`, `Undefined` and `UpdateValue[T]` to `core.util.typing_util` for partial updates: `UpdateValue[T]` is `T` or the `UNDEFINED` sentinel, so an update can tell "not provided" apart from an explicit `None`. Use it as a pydantic field default (`title: UpdateValue[str | None] = UNDEFINED`); request input never validates to `UNDEFINED` (a JSON `"UNDEFINED"` stays a string), and `x is not UNDEFINED` narrows `x` to `T` for type checkers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev85
3
+ Version: 0.5.3.dev87
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
@@ -14,6 +14,7 @@ Requires-Dist: uvicorn[standard]>=0.54.0; extra == "api"
14
14
  Provides-Extra: core-api
15
15
  Requires-Dist: starlette>=1.7.0; extra == "core-api"
16
16
  Requires-Dist: uvicorn[standard]>=0.54.0; extra == "core-api"
17
+ Requires-Dist: python-multipart>=0.0.29; extra == "core-api"
17
18
  Provides-Extra: storage
18
19
  Requires-Dist: aiobotocore>=3.9.2; extra == "storage"
19
20
  Provides-Extra: queues
@@ -7,10 +7,9 @@ from starlette.requests import Request
7
7
 
8
8
  from core.api.api_request import KibaApiRequest
9
9
  from core.api.api_response import KibaJSONResponse
10
+ from core.api.request_body import read_request_body
10
11
  from core.exceptions import BadRequestException
11
12
  from core.exceptions import InternalServerErrorException
12
- from core.util import json_util
13
- from core.util.typing_util import JsonObject
14
13
 
15
14
 
16
15
  def json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
@@ -22,16 +21,7 @@ def json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
22
21
  async def async_wrapper(receivedRequest: Request) -> KibaJSONResponse:
23
22
  pathParams = receivedRequest.path_params
24
23
  queryParams = receivedRequest.query_params
25
- bodyBytes = await receivedRequest.body()
26
- if len(bodyBytes) == 0:
27
- body: JsonObject = {}
28
- else:
29
- try:
30
- body = typing.cast(JsonObject, json_util.loads(bodyBytes.decode()))
31
- except json_util.JsonDecodeException as exception:
32
- raise BadRequestException(f'Invalid JSON body: {exception}')
33
- if not isinstance(body, dict):
34
- raise BadRequestException('Invalid JSON body: expected an object')
24
+ body = await read_request_body(request=receivedRequest)
35
25
  allParams = {**pathParams, **body, **queryParams}
36
26
  try:
37
27
  requestParams = requestType(**allParams)
@@ -0,0 +1,31 @@
1
+ import typing
2
+ from collections.abc import Mapping
3
+
4
+ from starlette.datastructures import UploadFile
5
+ from starlette.requests import Request
6
+
7
+ from core.api.uploaded_file import UploadedFile
8
+ from core.exceptions import BadRequestException
9
+ from core.util import json_util
10
+ from core.util.typing_util import JsonObject
11
+
12
+
13
+ async def read_request_body(request: Request) -> Mapping[str, object]:
14
+ if request.headers.get('Content-Type', '').startswith('multipart/form-data'):
15
+ form = await request.form()
16
+ 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
21
+ return body
22
+ bodyBytes = await request.body()
23
+ if len(bodyBytes) == 0:
24
+ return {}
25
+ try:
26
+ jsonBody = typing.cast(JsonObject, json_util.loads(bodyBytes.decode()))
27
+ except json_util.JsonDecodeException as exception:
28
+ raise BadRequestException(f'Invalid JSON body: {exception}')
29
+ if not isinstance(jsonBody, dict):
30
+ raise BadRequestException('Invalid JSON body: expected an object')
31
+ return jsonBody
@@ -9,10 +9,10 @@ from starlette.requests import Request
9
9
  from starlette.responses import StreamingResponse
10
10
 
11
11
  from core.api.api_request import KibaApiRequest
12
+ from core.api.request_body import read_request_body
12
13
  from core.exceptions import BadRequestException
13
14
  from core.exceptions import InternalServerErrorException
14
15
  from core.util import json_util
15
- from core.util.typing_util import JsonObject
16
16
 
17
17
 
18
18
  async def _convert_to_json_generator[T: BaseModel](response_iterator: AsyncIterator[T], expectedType: typing.Type[T]) -> AsyncIterator[bytes]:
@@ -31,16 +31,7 @@ def streaming_json_route[ApiRequest: BaseModel, ApiResponse: BaseModel](
31
31
  async def async_wrapper(receivedRequest: Request) -> StreamingResponse:
32
32
  pathParams = receivedRequest.path_params
33
33
  queryParams = receivedRequest.query_params
34
- bodyBytes = await receivedRequest.body()
35
- if len(bodyBytes) == 0:
36
- body: JsonObject = {}
37
- else:
38
- try:
39
- body = typing.cast(JsonObject, json_util.loads(bodyBytes.decode()))
40
- except json_util.JsonDecodeException as exception:
41
- raise BadRequestException(f'Invalid JSON body: {exception}')
42
- if not isinstance(body, dict):
43
- raise BadRequestException('Invalid JSON body: expected an object')
34
+ body = await read_request_body(request=receivedRequest)
44
35
  allParams = {**pathParams, **body, **queryParams}
45
36
  try:
46
37
  requestParams = requestType(**allParams)
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class UploadedFile(BaseModel):
5
+ filename: str | None
6
+ contentType: str | None
7
+ content: bytes
@@ -113,6 +113,14 @@ class AqsMessageQueue(MessageQueue[AqsMessage]):
113
113
  message.popReceipt = updatedMessage.pop_receipt
114
114
  return True
115
115
 
116
+ async def release_message(self, message: AqsMessage) -> None:
117
+ # NOTE(krishan711): a message whose lease was already lost is left alone
118
+ await self.extend_message_lease(message=message, expectedProcessingSeconds=0)
119
+
120
+ async def delete_expired_messages(self) -> int:
121
+ # NOTE(krishan711): azure deletes messages when they are completed and expires the rest itself
122
+ return 0
123
+
116
124
  async def get_message_count(self) -> int:
117
125
  if not self._aqsClient:
118
126
  raise InternalServerErrorException('You need to call .connect() before trying to count messages')
@@ -258,6 +258,27 @@ class CosmosMessageQueue(MessageQueue[CosmosMessage]):
258
258
  message.etag = str(updatedItem['_etag'])
259
259
  return True
260
260
 
261
+ async def release_message(self, message: CosmosMessage) -> None:
262
+ try:
263
+ await self.container.patch_item(
264
+ item=message.id,
265
+ partition_key=self.queueName,
266
+ patch_operations=[
267
+ {'op': 'set', 'path': '/leaseId', 'value': None},
268
+ {'op': 'set', 'path': '/visibleDate', 'value': time.time()},
269
+ ],
270
+ etag=message.etag,
271
+ match_condition=MatchConditions.IfNotModified,
272
+ )
273
+ except cosmos_exceptions.CosmosHttpResponseError as exception:
274
+ # NOTE(krishan711): 412 means another worker now owns the message and 404 that it was already completed
275
+ if exception.status_code not in {404, 412}:
276
+ raise
277
+
278
+ async def delete_expired_messages(self) -> int:
279
+ # NOTE(krishan711): cosmos keeps no message history, completed messages are removed straight away
280
+ return 0
281
+
261
282
  async def get_message_count(self) -> int:
262
283
  counts = self.container.query_items(
263
284
  query='SELECT COUNT(1) AS messageCount FROM c WHERE c.itemType = @itemType AND c.queueName = @queueName AND c.visibleDate <= @now',
@@ -47,6 +47,15 @@ class MessageQueue[MessageType: Message](ABC):
47
47
  async def extend_message_lease(self, message: MessageType, expectedProcessingSeconds: int) -> bool:
48
48
  raise NotImplementedError
49
49
 
50
+ @abc.abstractmethod
51
+ async def release_message(self, message: MessageType) -> None:
52
+ # NOTE(krishan711): hands a claimed message back to be claimed again straight away without using an attempt, e.g. when the worker shuts down
53
+ raise NotImplementedError
54
+
55
+ @abc.abstractmethod
56
+ async def delete_expired_messages(self) -> int:
57
+ raise NotImplementedError
58
+
50
59
  @abc.abstractmethod
51
60
  async def get_message_count(self) -> int:
52
61
  raise NotImplementedError
@@ -1,6 +1,7 @@
1
1
  import abc
2
2
  import asyncio
3
3
  import contextlib
4
+ import signal
4
5
  import sys
5
6
  import time
6
7
  import urllib.parse as urlparse
@@ -18,6 +19,7 @@ from core.util.value_holder import RequestIdHolder
18
19
 
19
20
  LOCKED_MAX_RETRY_COUNT = 3
20
21
  LOCKED_RETRY_DELAY_SECONDS = 30
22
+ SHUTDOWN_SIGNALS = (signal.SIGTERM, signal.SIGINT)
21
23
 
22
24
 
23
25
  class MessageProcessor(ABC):
@@ -40,14 +42,30 @@ class MessageLeaseLostException(InternalServerErrorException):
40
42
 
41
43
 
42
44
  class MessageQueueProcessor[MessageType: Message]:
43
- def __init__(self, queue: MessageQueue[MessageType], messageProcessor: MessageProcessor, notificationClients: list[NotificationClient], requestIdHolder: RequestIdHolder | None = None, pollLogIntervalSeconds: float = 600, shouldNotifyRetriedFailures: bool = True) -> None:
45
+ def __init__(
46
+ self,
47
+ queue: MessageQueue[MessageType],
48
+ messageProcessor: MessageProcessor,
49
+ notificationClients: list[NotificationClient],
50
+ requestIdHolder: RequestIdHolder | None = None,
51
+ pollLogIntervalSeconds: float = 600,
52
+ shouldNotifyRetriedFailures: bool = True,
53
+ deleteExpiredMessagesIntervalSeconds: float = 600,
54
+ ) -> None:
44
55
  self.queue = queue
45
56
  self.messageProcessor = messageProcessor
46
57
  self.notificationClients = notificationClients
47
58
  self.requestIdHolder = requestIdHolder
48
59
  self.pollLogIntervalSeconds = pollLogIntervalSeconds
49
60
  self.shouldNotifyRetriedFailures = shouldNotifyRetriedFailures
61
+ self.deleteExpiredMessagesIntervalSeconds = deleteExpiredMessagesIntervalSeconds
50
62
  self._lastPollLogTime: float | None = None
63
+ self._lastExpiredDeletionTime: float | None = None
64
+ self._shutdownEvent = asyncio.Event()
65
+
66
+ def request_shutdown(self) -> None:
67
+ # NOTE(krishan711): stops claiming new messages; the message being processed finishes and claimed messages not yet started are released
68
+ self._shutdownEvent.set()
51
69
 
52
70
  async def _handle_failure(self, message: MessageType, exception: Exception, requestId: str) -> int:
53
71
  logging.error('Caught exception whilst processing message:')
@@ -95,6 +113,7 @@ class MessageQueueProcessor[MessageType: Message]:
95
113
  # Cancellation (and other non-Exception errors) must still propagate, so those are never replaced.
96
114
  stopKeepAliveEvent = asyncio.Event()
97
115
  keepAliveTask = asyncio.create_task(self._keep_message_alive(message=message, expectedProcessingSeconds=expectedProcessingSeconds, stopEvent=stopKeepAliveEvent))
116
+ isLeaseLost = False
98
117
  try:
99
118
  try:
100
119
  await self.messageProcessor.process_message(message=message)
@@ -105,6 +124,10 @@ class MessageQueueProcessor[MessageType: Message]:
105
124
  if isLeaseLost and (inFlightException is None or isinstance(inFlightException, Exception)):
106
125
  raise MessageLeaseLostException
107
126
  await self.queue.complete_message(message=message)
127
+ except asyncio.CancelledError:
128
+ if not isLeaseLost:
129
+ await self._release_messages(messages=[message])
130
+ raise
108
131
  except MessageNeedsReprocessingException as exception:
109
132
  logging.info(msg=f'Scheduling reprocessing for message:{message.command} due to: {exception.originalException!s}')
110
133
  await self.queue.retry_message(message=message, delaySeconds=((message.postCount or 0) * exception.delaySeconds))
@@ -122,16 +145,44 @@ class MessageQueueProcessor[MessageType: Message]:
122
145
  if self.requestIdHolder:
123
146
  self.requestIdHolder.set_value(value=None)
124
147
 
148
+ async def _release_messages(self, messages: list[MessageType]) -> None:
149
+ for message in messages:
150
+ try:
151
+ await self.queue.release_message(message=message)
152
+ except Exception as exception: # noqa: BLE001
153
+ # NOTE(krishan711): a message that cannot be released is picked up again once its lease expires
154
+ logging.error(f'Failed to release message {message.command}:')
155
+ logging.exception(exception)
156
+
157
+ async def _delete_expired_messages_if_due(self) -> None:
158
+ now = time.monotonic()
159
+ if self._lastExpiredDeletionTime is not None and now - self._lastExpiredDeletionTime < self.deleteExpiredMessagesIntervalSeconds:
160
+ return
161
+ self._lastExpiredDeletionTime = now
162
+ try:
163
+ deletedCount = await self.queue.delete_expired_messages()
164
+ except Exception as exception: # noqa: BLE001
165
+ logging.error('Failed to delete expired messages:')
166
+ logging.exception(exception)
167
+ return
168
+ if deletedCount > 0:
169
+ logging.info(f'Deleted {deletedCount} expired messages')
170
+
125
171
  async def execute_batch(self, batchSize: int, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, shouldProcessInParallel: bool = False) -> int:
126
172
  now = time.monotonic()
127
173
  if self._lastPollLogTime is None or now - self._lastPollLogTime >= self.pollLogIntervalSeconds:
128
174
  logging.info('Retrieving messages...')
129
175
  self._lastPollLogTime = now
130
176
  messages = await self.queue.get_messages(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, limit=batchSize)
131
- if shouldProcessInParallel:
177
+ if self._shutdownEvent.is_set():
178
+ await self._release_messages(messages=messages)
179
+ elif shouldProcessInParallel:
132
180
  await asyncio.gather(*[self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds) for message in messages])
133
181
  else:
134
- for message in messages:
182
+ for index, message in enumerate(messages):
183
+ if self._shutdownEvent.is_set():
184
+ await self._release_messages(messages=messages[index:])
185
+ break
135
186
  await self._process_message(message=message, expectedProcessingSeconds=expectedProcessingSeconds)
136
187
  return len(messages)
137
188
 
@@ -139,14 +190,41 @@ class MessageQueueProcessor[MessageType: Message]:
139
190
  processedMessageCount = await self.execute_batch(batchSize=1, expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds)
140
191
  return processedMessageCount > 0
141
192
 
142
- async def run_batches(self, batchSize: int, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, sleepTime: int = 30, totalMessageLimit: int | None = None) -> int:
193
+ def _on_shutdown_signal(self, signalNumber: int) -> None:
194
+ logging.info(f'Received {signal.Signals(signalNumber).name}, finishing the current message before shutting down')
195
+ self.request_shutdown()
196
+ # NOTE(krishan711): a second signal falls back to the default handler so an impatient interrupt still stops the process
197
+ loop = asyncio.get_running_loop()
198
+ for shutdownSignal in SHUTDOWN_SIGNALS:
199
+ loop.remove_signal_handler(shutdownSignal)
200
+
201
+ async def run_batches(
202
+ self,
203
+ batchSize: int,
204
+ expectedProcessingSeconds: int = 300,
205
+ longPollSeconds: int = 20,
206
+ sleepTime: int = 30,
207
+ totalMessageLimit: int | None = None,
208
+ shouldProcessInParallel: bool = False,
209
+ ) -> int:
210
+ loop = asyncio.get_running_loop()
211
+ for shutdownSignal in SHUTDOWN_SIGNALS:
212
+ loop.add_signal_handler(shutdownSignal, self._on_shutdown_signal, shutdownSignal)
143
213
  processedMessageCount = 0
144
- while totalMessageLimit is None or processedMessageCount < totalMessageLimit:
145
- innerProcessedMessageCount = await self.execute_batch(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, batchSize=batchSize)
146
- if innerProcessedMessageCount == 0:
147
- logging.info('No message received.. sleeping')
148
- await asyncio.sleep(sleepTime)
149
- processedMessageCount += innerProcessedMessageCount
214
+ try:
215
+ while not self._shutdownEvent.is_set() and (totalMessageLimit is None or processedMessageCount < totalMessageLimit):
216
+ await self._delete_expired_messages_if_due()
217
+ innerProcessedMessageCount = await self.execute_batch(expectedProcessingSeconds=expectedProcessingSeconds, longPollSeconds=longPollSeconds, batchSize=batchSize, shouldProcessInParallel=shouldProcessInParallel)
218
+ if innerProcessedMessageCount == 0:
219
+ logging.info('No message received.. sleeping')
220
+ with contextlib.suppress(TimeoutError):
221
+ await asyncio.wait_for(self._shutdownEvent.wait(), timeout=sleepTime)
222
+ processedMessageCount += innerProcessedMessageCount
223
+ finally:
224
+ for shutdownSignal in SHUTDOWN_SIGNALS:
225
+ loop.remove_signal_handler(shutdownSignal)
226
+ if self._shutdownEvent.is_set():
227
+ logging.info('Shutdown requested, stopped processing messages')
150
228
  return processedMessageCount
151
229
 
152
230
  async def run(self, expectedProcessingSeconds: int = 300, longPollSeconds: int = 20, sleepTime: int = 30, totalMessageLimit: int | None = None) -> bool:
@@ -211,7 +211,7 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
211
211
  return result.first() is not None
212
212
 
213
213
  async def retry_message(self, message: SqlMessage, delaySeconds: int = 0) -> None:
214
- await self._reschedule_message(message=message, delaySeconds=delaySeconds, extraValues={})
214
+ await self._reschedule_message(message=message, delaySeconds=delaySeconds, shouldCountAttempt=True)
215
215
 
216
216
  async def fail_message(self, message: SqlMessage, errorMessage: str | None) -> bool:
217
217
  postCount = message.postCount or 0
@@ -220,21 +220,39 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
220
220
  query = self._owned_update(message=message).values(status=MESSAGE_STATUS_FAILED, lockToken=None, completedDate=self.database.now(), lastError=errorMessage)
221
221
  await self.database.execute(query=query, connection=connection)
222
222
  return False
223
- await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, extraValues={'lastError': errorMessage})
223
+ await self._reschedule_message(message=message, delaySeconds=self.failureRetryDelaySeconds * postCount, shouldCountAttempt=True, extraValues={'lastError': errorMessage})
224
224
  return True
225
225
 
226
- async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, extraValues: dict[str, Any]) -> None: # type: ignore[explicit-any]
226
+ async def release_message(self, message: SqlMessage) -> None:
227
+ await self._reschedule_message(message=message, delaySeconds=0, shouldCountAttempt=False)
228
+
229
+ async def _reschedule_message(self, message: SqlMessage, delaySeconds: float, shouldCountAttempt: bool, extraValues: dict[str, Any] | None = None) -> None: # type: ignore[explicit-any]
230
+ values = {'status': MESSAGE_STATUS_PENDING, 'lockToken': None, 'visibleDate': self.database.now(seconds=delaySeconds), **(extraValues or {})}
231
+ if shouldCountAttempt:
232
+ values |= {'postCount': self.table.c.postCount + 1, 'postDate': self.database.now()}
227
233
  async with self.database.create_transaction() as connection:
228
234
  try:
229
235
  async with connection.begin_nested():
230
- query = self._owned_update(message=message).values(status=MESSAGE_STATUS_PENDING, lockToken=None, visibleDate=self.database.now(seconds=delaySeconds), postCount=self.table.c.postCount + 1, postDate=self.database.now(), **extraValues)
236
+ query = self._owned_update(message=message).values(values)
231
237
  await self.database.execute(query=query, connection=connection)
232
238
  except sqlalchemy.exc.IntegrityError:
233
239
  # NOTE(krishan711): an identical message was enqueued while this one ran, so it will do the work instead
234
240
  query = self._owned_update(message=message).values(status=MESSAGE_STATUS_DEDUPLICATED, lockToken=None, completedDate=self.database.now())
235
241
  await self.database.execute(query=query, connection=connection)
236
242
 
237
- async def get_message_count(self, shouldSkipRemovingNonRetained: bool = False) -> int:
243
+ async def delete_expired_messages(self) -> int:
244
+ async with self.database.create_transaction() as connection:
245
+ query = (
246
+ sqlalchemy.delete(self.table)
247
+ .where(self.table.c.queueName == self.queueName)
248
+ .where(self.table.c.status.in_([MESSAGE_STATUS_SUCCEEDED, MESSAGE_STATUS_DEDUPLICATED]))
249
+ .where(self.table.c.completedDate < self.database.now(seconds=-self.retentionSeconds))
250
+ .returning(self.table.c.id)
251
+ )
252
+ result = await self.database.execute(query=query, connection=connection)
253
+ return len(result.all())
254
+
255
+ async def get_message_count(self) -> int:
238
256
  countQuery = (
239
257
  sqlalchemy.select(sqlalchemy.func.count())
240
258
  .select_from(self.table)
@@ -243,14 +261,6 @@ class SqlMessageQueue(MessageQueue[SqlMessage]):
243
261
  .where(self.table.c.visibleDate <= self.database.now())
244
262
  )
245
263
  async with self.database.create_transaction() as connection:
246
- if not shouldSkipRemovingNonRetained:
247
- deleteQuery = (
248
- sqlalchemy.delete(self.table)
249
- .where(self.table.c.queueName == self.queueName)
250
- .where(self.table.c.status.in_([MESSAGE_STATUS_SUCCEEDED, MESSAGE_STATUS_DEDUPLICATED]))
251
- .where(self.table.c.completedDate < self.database.now(seconds=-self.retentionSeconds))
252
- )
253
- await self.database.execute(query=deleteQuery, connection=connection)
254
264
  result = await self.database.execute(query=countQuery, connection=connection)
255
265
  return int(result.scalar_one())
256
266
 
@@ -131,6 +131,14 @@ class SqsMessageQueue(MessageQueue[SqsMessage]):
131
131
  raise
132
132
  return True
133
133
 
134
+ async def release_message(self, message: SqsMessage) -> None:
135
+ # NOTE(krishan711): a message whose lease was already lost is left alone; SQS still counts the next receive towards the queue's redrive policy
136
+ await self.extend_message_lease(message=message, expectedProcessingSeconds=0)
137
+
138
+ async def delete_expired_messages(self) -> int:
139
+ # NOTE(krishan711): SQS deletes messages when they are completed and expires the rest itself
140
+ return 0
141
+
134
142
  async def get_message_count(self) -> int:
135
143
  if not self._sqsClient:
136
144
  raise InternalServerErrorException('You need to call .connect() before trying to count messages')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev85
3
+ Version: 0.5.3.dev87
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
@@ -14,6 +14,7 @@ Requires-Dist: uvicorn[standard]>=0.54.0; extra == "api"
14
14
  Provides-Extra: core-api
15
15
  Requires-Dist: starlette>=1.7.0; extra == "core-api"
16
16
  Requires-Dist: uvicorn[standard]>=0.54.0; extra == "core-api"
17
+ Requires-Dist: python-multipart>=0.0.29; extra == "core-api"
17
18
  Provides-Extra: storage
18
19
  Requires-Dist: aiobotocore>=3.9.2; extra == "storage"
19
20
  Provides-Extra: queues
@@ -26,11 +26,13 @@ core/api/health.py
26
26
  core/api/json_route.py
27
27
  core/api/openapi.py
28
28
  core/api/rate_limit.py
29
+ core/api/request_body.py
29
30
  core/api/request_context.py
30
31
  core/api/route.py
31
32
  core/api/route_auth.py
32
33
  core/api/route_metadata.py
33
34
  core/api/streaming_json_route.py
35
+ core/api/uploaded_file.py
34
36
  core/api/middleware/__init__.py
35
37
  core/api/middleware/database_connection_middleware.py
36
38
  core/api/middleware/exception_handling_middleware.py
@@ -9,6 +9,7 @@ uvicorn[standard]>=0.54.0
9
9
  [core-api]
10
10
  starlette>=1.7.0
11
11
  uvicorn[standard]>=0.54.0
12
+ python-multipart>=0.0.29
12
13
 
13
14
  [cron]
14
15
  apscheduler<4,>=3.11.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev85"
3
+ version = "0.5.3.dev87"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -18,6 +18,7 @@ api = [
18
18
  core-api = [
19
19
  "starlette>=1.7.0",
20
20
  "uvicorn[standard]>=0.54.0",
21
+ "python-multipart>=0.0.29",
21
22
  ]
22
23
  storage = [
23
24
  "aiobotocore>=3.9.2",
@@ -6,6 +6,7 @@ from pydantic import BaseModel
6
6
 
7
7
  from core.api.json_route import json_route
8
8
  from core.api.api_request import KibaApiRequest
9
+ from core.api.uploaded_file import UploadedFile
9
10
  from core.api.middleware.exception_handling_middleware import ExceptionHandlingMiddleware
10
11
  from core.api.request_context import RequestContext
11
12
  from core.api.request_context import RequestContextHolder
@@ -30,6 +31,18 @@ class ExampleResponse(BaseModel):
30
31
  path_echo: str | None = None
31
32
 
32
33
 
34
+ class UploadRequest(BaseModel):
35
+ file: UploadedFile
36
+ age: int
37
+
38
+
39
+ class UploadResponse(BaseModel):
40
+ filename: str | None
41
+ content_type: str | None
42
+ content: str
43
+ age: int
44
+
45
+
33
46
  @pytest.fixture
34
47
  def client():
35
48
  @json_route(requestType=ExampleRequest, responseType=ExampleResponse)
@@ -52,9 +65,20 @@ def client():
52
65
  )
53
66
 
54
67
 
68
+ @json_route(requestType=UploadRequest, responseType=UploadResponse)
69
+ async def test_upload_endpoint(request: KibaApiRequest[UploadRequest]) -> UploadResponse:
70
+ return UploadResponse(
71
+ filename=request.data.file.filename,
72
+ content_type=request.data.file.contentType,
73
+ content=request.data.file.content.decode(),
74
+ age=request.data.age,
75
+ )
76
+
77
+
55
78
  app = Starlette(routes=[
56
79
  Route("/test", test_endpoint, methods=["POST"]),
57
80
  Route("/test/{path_param}", test_path_endpoint, methods=["POST"]),
81
+ Route("/upload", test_upload_endpoint, methods=["POST"]),
58
82
  ])
59
83
  app.add_middleware(ExceptionHandlingMiddleware)
60
84
  app.add_middleware(RequestContextMiddleware, requestContextHolder=requestContextHolder, requestContextFactory=create_request_context)
@@ -145,3 +169,23 @@ def test_json_route_with_non_object_json(client, content):
145
169
  )
146
170
  assert response.status_code == 400
147
171
  assert "expected an object" in response.json().get("message", "")
172
+
173
+
174
+ def test_json_route_with_multipart_body_passes_files_and_fields(client):
175
+ response = client.post(
176
+ "/upload",
177
+ files={"file": ("scan.dcm", b"file-content", "application/dicom")},
178
+ data={"age": "41"},
179
+ )
180
+ assert response.status_code == 200
181
+ assert response.json() == {"filename": "scan.dcm", "content_type": "application/dicom", "content": "file-content", "age": 41}
182
+
183
+
184
+ def test_json_route_with_multipart_body_rejects_repeated_fields(client):
185
+ response = client.post(
186
+ "/upload",
187
+ files=[("file", ("first.dcm", b"first", "application/dicom")), ("file", ("second.dcm", b"second", "application/dicom"))],
188
+ data={"age": "41"},
189
+ )
190
+ assert response.status_code == 400
191
+ assert "file is given more than once" in response.json().get("message", "")
@@ -43,6 +43,13 @@ async def _claim(queue: AqsMessageQueue, expectedProcessingSeconds: int = 300) -
43
43
  return message
44
44
 
45
45
 
46
+ async def test_released_message_can_be_received_again_straight_away(workers: tuple[AqsMessageQueue, AqsMessageQueue]):
47
+ workerA, workerB = workers
48
+ await workerA.send_message(message=_message())
49
+ await workerA.release_message(message=await _claim(queue=workerA))
50
+ await _claim(queue=workerB)
51
+
52
+
46
53
  async def test_extended_message_stays_invisible_and_can_still_be_completed(workers: tuple[AqsMessageQueue, AqsMessageQueue]):
47
54
  workerA, workerB = workers
48
55
  await workerA.send_message(message=_message())
@@ -85,6 +85,15 @@ async def test_completed_message_is_removed_and_can_be_sent_again(workers: tuple
85
85
  assert await workerA.get_message_count() == 1
86
86
 
87
87
 
88
+ async def test_release_hands_the_message_back_without_using_an_attempt(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
89
+ workerA, workerB = workers
90
+ await workerA.send_message(message=_message())
91
+ message = await _claim(queue=workerA)
92
+ await workerA.release_message(message=message)
93
+ releasedMessage = await _claim(queue=workerB)
94
+ assert (releasedMessage.id, releasedMessage.postCount) == (message.id, 1)
95
+
96
+
88
97
  async def test_retry_reschedules_the_same_message_with_a_delay(workers: tuple[CosmosMessageQueue, CosmosMessageQueue]):
89
98
  workerA, _ = workers
90
99
  await workerA.send_message(message=_message())