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.
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/CHANGELOG.md +5 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/PKG-INFO +2 -1
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/json_route.py +2 -12
- kiba_core-0.5.3.dev87/core/api/request_body.py +31 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/streaming_json_route.py +2 -11
- kiba_core-0.5.3.dev87/core/api/uploaded_file.py +7 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/aqs.py +8 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/cosmos.py +21 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/message_queue.py +9 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/message_queue_processor.py +88 -10
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/sql.py +23 -13
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/sqs.py +8 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/PKG-INFO +2 -1
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/SOURCES.txt +2 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/requires.txt +1 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/pyproject.toml +2 -1
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_json_route.py +44 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_aqs_queue.py +7 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_cosmos_queue.py +9 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_sql_queue.py +84 -3
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_sqs_queue.py +7 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/uv.lock +11 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/pull_request_template.md +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/deploy.yml +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/publish.yml +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/pull-request.yml +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.github/workflows/release.yml +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/.gitignore +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/Dockerfile +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/README.md +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/api_request.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/api_response.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/authorizer.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/default_routes.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/health.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/database_connection_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/logging_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/middleware/server_headers_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/openapi.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/rate_limit.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/request_context.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route_auth.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/api/route_metadata.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/aws_requester.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/cache.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/dict_cache.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/caching/file_cache.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/api_request_cron_job_processor.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/cron_executor.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/cron_job_processor.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/cron/model.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/exceptions.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/basic_authentication.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/jwt.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/http/rest_method.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/lock.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/model.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/locks/sql.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/logging.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/discord_client.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/notification_client.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/notifications/slack_client.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/py.typed +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/queues/model.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/requester/requester.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/s3_manager.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/service_client.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/database.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/entity_repository.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/retriever.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/store/saver.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/async_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/chain_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/date_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/dict_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/file_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/hashing_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/http_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/json_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/list_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/process_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/string_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/typing_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/url_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/util/value_holder.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/eth_client.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/eth_client_registry.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/core/web3/multicall3.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/dependency_links.txt +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/kiba_core.egg-info/top_level.txt +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/makefile +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/setup.cfg +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_api_response.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_authorizer.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_exception_handling_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_openapi.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_origin_ip_middleware.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_rate_limit.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_request_context.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_route.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/api/test_streaming_json_route.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/caching/test_dict_cache.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/caching/test_file_cache.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/cron/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/cron/test_cron_executor.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/locks/test_sql_lock.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/conftest.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/queues/test_message_queue_processor.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/requester/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/requester/test_requester.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_database.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_entity_repository.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_exceptions.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/test_logging.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_async_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_chain_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_date_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_dict_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_file_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_json_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_list_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_process_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_string_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/util/test_url_util.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/web3/__init__.py +0 -0
- {kiba_core-0.5.3.dev85 → kiba_core-0.5.3.dev87}/tests/web3/test_eth_client_registry.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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__(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kiba-core"
|
|
3
|
-
version = "0.5.3.
|
|
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())
|