kiba-core 0.5.3.dev68__tar.gz → 0.5.3.dev70__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 (136) hide show
  1. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/CHANGELOG.md +2 -0
  2. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/PKG-INFO +3 -1
  3. kiba_core-0.5.3.dev70/core/cron/api_request_cron_job_processor.py +34 -0
  4. kiba_core-0.5.3.dev70/core/cron/cron_executor.py +68 -0
  5. kiba_core-0.5.3.dev70/core/cron/cron_job_processor.py +13 -0
  6. kiba_core-0.5.3.dev70/core/cron/model.py +19 -0
  7. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/requester/requester.py +19 -1
  8. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/kiba_core.egg-info/PKG-INFO +3 -1
  9. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/kiba_core.egg-info/SOURCES.txt +7 -0
  10. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/kiba_core.egg-info/requires.txt +3 -0
  11. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/pyproject.toml +4 -1
  12. kiba_core-0.5.3.dev70/tests/cron/test_cron_executor.py +75 -0
  13. kiba_core-0.5.3.dev70/tests/requester/__init__.py +0 -0
  14. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/requester/test_requester.py +19 -0
  15. kiba_core-0.5.3.dev70/tests/web3/__init__.py +0 -0
  16. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/uv.lock +38 -1
  17. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/.github/pull_request_template.md +0 -0
  18. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/.github/workflows/deploy.yml +0 -0
  19. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/.github/workflows/pull-request.yml +0 -0
  20. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/.github/workflows/release.yml +0 -0
  21. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/.gitignore +0 -0
  22. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/Dockerfile +0 -0
  23. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/README.md +0 -0
  24. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/__init__.py +0 -0
  26. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/api_request.py +0 -0
  27. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/api_response.py +0 -0
  28. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/authorizer.py +0 -0
  29. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/default_routes.py +0 -0
  30. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/health.py +0 -0
  31. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/json_route.py +0 -0
  32. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/__init__.py +0 -0
  33. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/database_connection_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/exception_handling_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/logging_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/origin_ip_middleware.py +0 -0
  37. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/middleware/server_headers_middleware.py +0 -0
  38. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/openapi.py +0 -0
  39. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/rate_limit.py +0 -0
  40. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/request_context.py +0 -0
  41. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/route.py +0 -0
  42. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/route_auth.py +0 -0
  43. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/route_metadata.py +0 -0
  44. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/api/streaming_json_route.py +0 -0
  45. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/aws_requester.py +0 -0
  46. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/caching/__init__.py +0 -0
  47. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/caching/cache.py +0 -0
  48. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/caching/dict_cache.py +0 -0
  49. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/caching/file_cache.py +0 -0
  50. {kiba_core-0.5.3.dev68/core/locks → kiba_core-0.5.3.dev70/core/cron}/__init__.py +0 -0
  51. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/exceptions.py +0 -0
  52. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/http/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/http/basic_authentication.py +0 -0
  54. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/http/jwt.py +0 -0
  55. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/http/rest_method.py +0 -0
  56. {kiba_core-0.5.3.dev68/core/notifications → kiba_core-0.5.3.dev70/core/locks}/__init__.py +0 -0
  57. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/locks/lock.py +0 -0
  58. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/locks/model.py +0 -0
  59. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/locks/sql.py +0 -0
  60. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/logging.py +0 -0
  61. {kiba_core-0.5.3.dev68/core/queues → kiba_core-0.5.3.dev70/core/notifications}/__init__.py +0 -0
  62. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/notifications/discord_client.py +0 -0
  63. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/notifications/notification_client.py +0 -0
  64. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/notifications/slack_client.py +0 -0
  65. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/py.typed +0 -0
  66. {kiba_core-0.5.3.dev68/core/store → kiba_core-0.5.3.dev70/core/queues}/__init__.py +0 -0
  67. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/aqs.py +0 -0
  68. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/cosmos.py +0 -0
  69. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/message_queue.py +0 -0
  70. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/message_queue_processor.py +0 -0
  71. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/model.py +0 -0
  72. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/sql.py +0 -0
  73. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/queues/sqs.py +0 -0
  74. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/requester/__init__.py +0 -0
  75. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/s3_manager.py +0 -0
  76. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/service_client.py +0 -0
  77. {kiba_core-0.5.3.dev68/core/util → kiba_core-0.5.3.dev70/core/store}/__init__.py +0 -0
  78. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/store/database.py +0 -0
  79. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/store/retriever.py +0 -0
  80. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/store/saver.py +0 -0
  81. {kiba_core-0.5.3.dev68/core/web3 → kiba_core-0.5.3.dev70/core/util}/__init__.py +0 -0
  82. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/async_util.py +0 -0
  83. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/chain_util.py +0 -0
  84. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/date_util.py +0 -0
  85. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/dict_util.py +0 -0
  86. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/file_util.py +0 -0
  87. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/hashing_util.py +0 -0
  88. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/http_util.py +0 -0
  89. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/json_util.py +0 -0
  90. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/list_util.py +0 -0
  91. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/process_util.py +0 -0
  92. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/string_util.py +0 -0
  93. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/typing_util.py +0 -0
  94. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/url_util.py +0 -0
  95. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/util/value_holder.py +0 -0
  96. {kiba_core-0.5.3.dev68/tests/requester → kiba_core-0.5.3.dev70/core/web3}/__init__.py +0 -0
  97. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/web3/eth_client.py +0 -0
  98. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/core/web3/multicall3.py +0 -0
  99. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/kiba_core.egg-info/dependency_links.txt +0 -0
  100. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/kiba_core.egg-info/top_level.txt +0 -0
  101. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/makefile +0 -0
  102. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/setup.cfg +0 -0
  103. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/__init__.py +0 -0
  104. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_api_response.py +0 -0
  105. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_authorizer.py +0 -0
  106. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_exception_handling_middleware.py +0 -0
  107. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_json_route.py +0 -0
  108. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_openapi.py +0 -0
  109. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_origin_ip_middleware.py +0 -0
  110. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_rate_limit.py +0 -0
  111. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_request_context.py +0 -0
  112. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_route.py +0 -0
  113. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/api/test_streaming_json_route.py +0 -0
  114. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/caching/test_dict_cache.py +0 -0
  115. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/caching/test_file_cache.py +0 -0
  116. {kiba_core-0.5.3.dev68/tests/web3 → kiba_core-0.5.3.dev70/tests/cron}/__init__.py +0 -0
  117. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/locks/test_sql_lock.py +0 -0
  118. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/queues/conftest.py +0 -0
  119. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/queues/test_aqs_queue.py +0 -0
  120. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/queues/test_cosmos_queue.py +0 -0
  121. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/queues/test_sql_queue.py +0 -0
  122. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/queues/test_sqs_queue.py +0 -0
  123. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/test_database.py +0 -0
  124. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/test_exceptions.py +0 -0
  125. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/__init__.py +0 -0
  126. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_async_util.py +0 -0
  127. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_chain_util.py +0 -0
  128. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_date_util.py +0 -0
  129. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_dict_util.py +0 -0
  130. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_file_util.py +0 -0
  131. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_json_util.py +0 -0
  132. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_list_util.py +0 -0
  133. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_process_util.py +0 -0
  134. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_string_util.py +0 -0
  135. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/util/test_url_util.py +0 -0
  136. {kiba_core-0.5.3.dev68 → kiba_core-0.5.3.dev70}/tests/web3/test_rest_eth_client.py +0 -0
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MINOR] Added `core.cron` (install with the `cron` extra): `CronJob` schedules (interval with jitter, or crontab) that declare a `jobType`, `CronJobProcessor`s that declare the `jobTypes` they process, and `CronExecutor`, which takes a list of processors, routes each job to the processor for its type (failing at startup if a job type has no processor or more than one), runs jobs on an APScheduler scheduler, logs each run as a `CRON` api log with its status and duration, and keeps running when a job fails; also added `ApiRequestCronJob` with `ApiRequestCronJobProcessor` (needs the `requester` extra) for jobs that call a URL
12
+ - [MAJOR] Updated `Requester` to raise `RequesterTimeoutException` (a `GatewayTimeoutException`, 504) instead of `httpx` timeout exceptions; it carries the method, the URL without credentials or query string, the timeout, how long the request ran and the `httpx` timeout type, and keeps the original exception as its cause. Code catching `httpx.TimeoutException` (or `ReadTimeout`, `ConnectTimeout`, …) around `Requester` calls must catch `RequesterTimeoutException` instead
11
13
  - [PATCH] Updated `MessageQueueProcessor` failure notifications to show the exception type before its message, so failures from exceptions without text (e.g. `httpx` timeouts) are still identifiable
12
14
  - [MINOR] Added `authorize_bearer_jwt_request` for validating bearer JWTs in `RouteAuthResolver` implementations; it and `authorize_bearer_jwt` now only strip the leading `Bearer ` prefix, let cancellation propagate, and log why a JWT was rejected
13
15
  - [MINOR] Made the `route` `authResolver` optional for routes without `auth`; setting `auth` without an `authResolver` raises `ValueError` at registration
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev68
3
+ Version: 0.5.3.dev70
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
@@ -34,6 +34,8 @@ Provides-Extra: requester
34
34
  Requires-Dist: httpx2>=2.13.0; extra == "requester"
35
35
  Provides-Extra: web3
36
36
  Requires-Dist: web3<8.0.0,>=7.16.0; extra == "web3"
37
+ Provides-Extra: cron
38
+ Requires-Dist: apscheduler<4,>=3.11.0; extra == "cron"
37
39
  Provides-Extra: types
38
40
  Requires-Dist: types-aiobotocore[essential]>=3.9.1; extra == "types"
39
41
  Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "types"
@@ -0,0 +1,34 @@
1
+ from typing import ClassVar
2
+
3
+ from core.cron.cron_job_processor import CronJobProcessor
4
+ from core.cron.model import CronJob
5
+ from core.exceptions import KibaException
6
+ from core.requester import Requester
7
+ from core.util.typing_util import Json
8
+
9
+
10
+ class ApiRequestCronJob(CronJob):
11
+ jobType: ClassVar[str] = 'api_request'
12
+ method: str
13
+ url: str
14
+ dataDict: Json | None = None
15
+ headers: dict[str, str] | None = None
16
+ timeout: int | None = None
17
+
18
+ @property
19
+ def query(self) -> dict[str, str]:
20
+ if not isinstance(self.dataDict, dict):
21
+ return {}
22
+ return {key: str(value) for key, value in self.dataDict.items() if isinstance(value, str | int | float | bool)}
23
+
24
+
25
+ class ApiRequestCronJobProcessor(CronJobProcessor):
26
+ jobTypes: ClassVar[frozenset[str]] = frozenset({ApiRequestCronJob.jobType})
27
+
28
+ def __init__(self, requester: Requester) -> None:
29
+ self.requester = requester
30
+
31
+ async def process_job(self, job: CronJob) -> None:
32
+ if not isinstance(job, ApiRequestCronJob):
33
+ raise KibaException(message=f'{type(self).__name__} cannot process {type(job).__name__}')
34
+ await self.requester.make_request(method=job.method, url=job.url, dataDict=job.dataDict, headers=job.headers, timeout=job.timeout)
@@ -0,0 +1,68 @@
1
+ import asyncio
2
+ import datetime
3
+ import time
4
+ import urllib.parse as urlparse
5
+ from collections.abc import Sequence
6
+
7
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
8
+ from apscheduler.triggers.cron import CronTrigger # type: ignore[import-untyped]
9
+ from apscheduler.triggers.interval import IntervalTrigger # type: ignore[import-untyped]
10
+
11
+ from core import logging
12
+ from core.cron.cron_job_processor import CronJobProcessor
13
+ from core.cron.model import CronJob
14
+ from core.exceptions import KibaException
15
+
16
+ # NOTE(krishan711): interval jobs are anchored to a fixed date so a restart keeps the same schedule instead of restarting every interval from boot
17
+ INTERVAL_START_DATE = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
18
+
19
+
20
+ class CronExecutor:
21
+ def __init__(self, jobs: Sequence[CronJob], jobProcessors: Sequence[CronJobProcessor]) -> None:
22
+ self.jobs = jobs
23
+ self.processorByJobType: dict[str, CronJobProcessor] = {}
24
+ for jobProcessor in jobProcessors:
25
+ for jobType in jobProcessor.jobTypes:
26
+ existingProcessor = self.processorByJobType.get(jobType)
27
+ if existingProcessor is not None:
28
+ raise KibaException(message=f'Cron job type {jobType} is handled by both {type(existingProcessor).__name__} and {type(jobProcessor).__name__}')
29
+ self.processorByJobType[jobType] = jobProcessor
30
+ unprocessableJobNames = [job.name for job in jobs if job.jobType not in self.processorByJobType]
31
+ if unprocessableJobNames:
32
+ raise KibaException(message=f'No cron job processor for jobs: {", ".join(unprocessableJobNames)}')
33
+ self.scheduler = AsyncIOScheduler()
34
+
35
+ async def execute_job(self, job: CronJob) -> None:
36
+ path = job.name.replace(' ', '_').replace(':', '_').lower()
37
+ query = urlparse.urlencode(job.query, doseq=True)
38
+ logging.api(action='CRON', path=path, pathPattern=path, query=query)
39
+ startTime = time.time()
40
+ statusCode = 200
41
+ try:
42
+ await self.processorByJobType[job.jobType].process_job(job=job)
43
+ except Exception as exception: # noqa: BLE001
44
+ statusCode = exception.statusCode if isinstance(exception, KibaException) else 500
45
+ logging.error('Caught exception whilst processing cron job:')
46
+ logging.exception(exception)
47
+ duration = time.time() - startTime
48
+ logging.api(action='CRON', path=path, pathPattern=path, query=query, response=statusCode, duration=duration)
49
+
50
+ def _build_trigger(self, job: CronJob) -> CronTrigger | IntervalTrigger:
51
+ if job.crontab:
52
+ return CronTrigger.from_crontab(expr=job.crontab)
53
+ return IntervalTrigger(weeks=job.weeks, days=job.days, hours=job.hours, minutes=job.minutes, seconds=job.seconds, jitter=job.jitter, start_date=INTERVAL_START_DATE)
54
+
55
+ async def start(self) -> None:
56
+ logging.info(f'Initializing CronExecutor with {len(self.jobs)} jobs')
57
+ for job in self.jobs:
58
+ self.scheduler.add_job(func=self.execute_job, kwargs={'job': job}, trigger=self._build_trigger(job=job), id=job.name, name=job.name, replace_existing=True)
59
+ self.scheduler.start()
60
+ logging.info('CronExecutor started successfully')
61
+
62
+ async def run(self) -> None:
63
+ await self.start()
64
+ try:
65
+ await asyncio.Event().wait()
66
+ finally:
67
+ logging.info('Shutting down scheduler...')
68
+ self.scheduler.shutdown()
@@ -0,0 +1,13 @@
1
+ import abc
2
+ from abc import ABC
3
+ from typing import ClassVar
4
+
5
+ from core.cron.model import CronJob
6
+
7
+
8
+ class CronJobProcessor(ABC):
9
+ jobTypes: ClassVar[frozenset[str]]
10
+
11
+ @abc.abstractmethod
12
+ async def process_job(self, job: CronJob) -> None:
13
+ raise NotImplementedError
@@ -0,0 +1,19 @@
1
+ from typing import ClassVar
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class CronJob(BaseModel):
7
+ jobType: ClassVar[str]
8
+ name: str
9
+ weeks: int = 0
10
+ days: int = 0
11
+ hours: int = 0
12
+ minutes: int = 0
13
+ seconds: int = 0
14
+ jitter: int = 0
15
+ crontab: str | None = None
16
+
17
+ @property
18
+ def query(self) -> dict[str, str]:
19
+ return {}
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import time
2
3
  import typing
3
4
  import urllib.parse as urlparse
4
5
  from collections.abc import Mapping
@@ -13,6 +14,7 @@ import httpx2 as httpx
13
14
 
14
15
  from core import logging
15
16
  from core.exceptions import HTTP_EXCEPTIONS_MAP
17
+ from core.exceptions import GatewayTimeoutException
16
18
  from core.exceptions import KibaException
17
19
  from core.util import dict_util
18
20
  from core.util import file_util
@@ -42,6 +44,18 @@ class ResponseException(KibaException):
42
44
  self.headers = headers
43
45
 
44
46
 
47
+ class RequesterTimeoutException(GatewayTimeoutException):
48
+ def __init__(self, method: str, url: str, timeoutSeconds: float | None, durationSeconds: float, timeoutType: str) -> None:
49
+ urlParts = urlparse.urlsplit(url)
50
+ # NOTE(krishan711): drop credentials and the query string (where api keys usually live) since this message ends up in logs and notifications
51
+ self.url = urlparse.urlunsplit((urlParts.scheme, urlParts.netloc.rpartition('@')[2], urlParts.path, '', ''))
52
+ self.method = method
53
+ self.timeoutSeconds = timeoutSeconds
54
+ self.durationSeconds = durationSeconds
55
+ self.timeoutType = timeoutType
56
+ super().__init__(message=f'{method} {self.url} timed out after {durationSeconds:.1f}s ({timeoutType}, timeout {timeoutSeconds}s)')
57
+
58
+
45
59
  class Requester:
46
60
  def __init__(self, headers: Mapping[str, str] | None = None, shouldFollowRedirects: bool = True) -> None:
47
61
  self.headers = headers or {}
@@ -136,7 +150,11 @@ class Requester:
136
150
  else:
137
151
  logging.error('Error: formFiles should only be passed into POST requests.')
138
152
  request = self.client.build_request(method=method, url=url, content=content, data=innerData, files=files, timeout=timeout, headers=requestHeaders)
139
- httpxResponse = await self.client.send(request=request)
153
+ startTime = time.monotonic()
154
+ try:
155
+ httpxResponse = await self.client.send(request=request)
156
+ except httpx.TimeoutException as exception:
157
+ raise RequesterTimeoutException(method=method, url=str(request.url), timeoutSeconds=timeout, durationSeconds=time.monotonic() - startTime, timeoutType=exception.__class__.__name__) from exception
140
158
  if 400 <= httpxResponse.status_code < 600: # noqa: PLR2004
141
159
  message = httpxResponse.text
142
160
  if not message and httpxResponse.status_code == 401 and httpxResponse.headers.get('www-authenticate'): # noqa: PLR2004
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev68
3
+ Version: 0.5.3.dev70
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
@@ -34,6 +34,8 @@ Provides-Extra: requester
34
34
  Requires-Dist: httpx2>=2.13.0; extra == "requester"
35
35
  Provides-Extra: web3
36
36
  Requires-Dist: web3<8.0.0,>=7.16.0; extra == "web3"
37
+ Provides-Extra: cron
38
+ Requires-Dist: apscheduler<4,>=3.11.0; extra == "cron"
37
39
  Provides-Extra: types
38
40
  Requires-Dist: types-aiobotocore[essential]>=3.9.1; extra == "types"
39
41
  Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "types"
@@ -40,6 +40,11 @@ core/caching/__init__.py
40
40
  core/caching/cache.py
41
41
  core/caching/dict_cache.py
42
42
  core/caching/file_cache.py
43
+ core/cron/__init__.py
44
+ core/cron/api_request_cron_job_processor.py
45
+ core/cron/cron_executor.py
46
+ core/cron/cron_job_processor.py
47
+ core/cron/model.py
43
48
  core/http/__init__.py
44
49
  core/http/basic_authentication.py
45
50
  core/http/jwt.py
@@ -104,6 +109,8 @@ tests/api/test_route.py
104
109
  tests/api/test_streaming_json_route.py
105
110
  tests/caching/test_dict_cache.py
106
111
  tests/caching/test_file_cache.py
112
+ tests/cron/__init__.py
113
+ tests/cron/test_cron_executor.py
107
114
  tests/locks/test_sql_lock.py
108
115
  tests/queues/conftest.py
109
116
  tests/queues/test_aqs_queue.py
@@ -10,6 +10,9 @@ uvicorn[standard]>=0.53.0
10
10
  starlette>=1.6.0
11
11
  uvicorn[standard]>=0.53.0
12
12
 
13
+ [cron]
14
+ apscheduler<4,>=3.11.0
15
+
13
16
  [database-psql]
14
17
  sqlalchemy[asyncio]>=2.0.54
15
18
  asyncpg>=0.31.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev68"
3
+ version = "0.5.3.dev70"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -49,6 +49,9 @@ requester = [
49
49
  web3 = [
50
50
  "web3>=7.16.0,<8.0.0",
51
51
  ]
52
+ cron = [
53
+ "apscheduler>=3.11.0,<4",
54
+ ]
52
55
  types = [
53
56
  "types-aiobotocore[essential]>=3.9.1",
54
57
  "types-aiofiles>=25.1.0.20260518",
@@ -0,0 +1,75 @@
1
+ from typing import ClassVar
2
+
3
+ import pytest
4
+
5
+ from core.cron.cron_executor import CronExecutor
6
+ from core.cron.cron_job_processor import CronJobProcessor
7
+ from core.cron.model import CronJob
8
+ from core.exceptions import KibaException
9
+
10
+
11
+ class EmailCronJob(CronJob):
12
+ jobType: ClassVar[str] = 'email'
13
+
14
+
15
+ class ReportCronJob(CronJob):
16
+ jobType: ClassVar[str] = 'report'
17
+
18
+
19
+ class RecordingProcessor(CronJobProcessor):
20
+ def __init__(self, failingJobNames: frozenset[str] = frozenset()) -> None:
21
+ self.failingJobNames = failingJobNames
22
+ self.processedJobNames: list[str] = []
23
+
24
+ async def process_job(self, job: CronJob) -> None:
25
+ self.processedJobNames.append(job.name)
26
+ if job.name in self.failingJobNames:
27
+ raise ValueError(f'{job.name} failed')
28
+
29
+
30
+ class EmailProcessor(RecordingProcessor):
31
+ jobTypes: ClassVar[frozenset[str]] = frozenset({EmailCronJob.jobType})
32
+
33
+
34
+ class ReportProcessor(RecordingProcessor):
35
+ jobTypes: ClassVar[frozenset[str]] = frozenset({ReportCronJob.jobType})
36
+
37
+
38
+ class EmailAndReportProcessor(RecordingProcessor):
39
+ jobTypes: ClassVar[frozenset[str]] = frozenset({EmailCronJob.jobType, ReportCronJob.jobType})
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_jobs_are_routed_to_the_processor_for_their_type() -> None:
44
+ emailProcessor = EmailProcessor()
45
+ reportProcessor = ReportProcessor()
46
+ emailJob = EmailCronJob(name='send-emails', minutes=1)
47
+ reportJob = ReportCronJob(name='build-report', minutes=1)
48
+ executor = CronExecutor(jobs=[emailJob, reportJob], jobProcessors=[emailProcessor, reportProcessor])
49
+
50
+ await executor.execute_job(job=reportJob)
51
+ await executor.execute_job(job=emailJob)
52
+
53
+ assert (emailProcessor.processedJobNames, reportProcessor.processedJobNames) == (['send-emails'], ['build-report'])
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_failing_job_does_not_stop_later_jobs() -> None:
58
+ processor = EmailProcessor(failingJobNames=frozenset({'first'}))
59
+ first, second = EmailCronJob(name='first', minutes=1), EmailCronJob(name='second', minutes=1)
60
+ executor = CronExecutor(jobs=[first, second], jobProcessors=[processor])
61
+
62
+ await executor.execute_job(job=first)
63
+ await executor.execute_job(job=second)
64
+
65
+ assert processor.processedJobNames == ['first', 'second']
66
+
67
+
68
+ def test_job_without_a_processor_fails_at_startup() -> None:
69
+ with pytest.raises(KibaException, match='No cron job processor for jobs: build-report'):
70
+ CronExecutor(jobs=[ReportCronJob(name='build-report', minutes=1)], jobProcessors=[EmailProcessor()])
71
+
72
+
73
+ def test_job_type_claimed_by_two_processors_fails_at_startup() -> None:
74
+ with pytest.raises(KibaException, match='Cron job type email is handled by both'):
75
+ CronExecutor(jobs=[], jobProcessors=[EmailProcessor(), EmailAndReportProcessor()])
File without changes
@@ -4,6 +4,7 @@ import pytest
4
4
  from core.exceptions import NotFoundException
5
5
  from core.exceptions import TooManyRequestsException
6
6
  from core.requester.requester import Requester
7
+ from core.requester.requester import RequesterTimeoutException
7
8
 
8
9
 
9
10
  class TestRequesterExceptionHeaders:
@@ -39,3 +40,21 @@ class TestRequesterExceptionHeaders:
39
40
  requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
40
41
  with pytest.raises(NotFoundException):
41
42
  await requester.get(url='https://example.com/resource')
43
+
44
+
45
+ class TestRequesterTimeouts:
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_timeout_raises_requester_timeout_with_request_details(self):
49
+ def handler(request: httpx.Request) -> httpx.Response:
50
+ raise httpx.ReadTimeout('', request=request)
51
+
52
+ requester = Requester()
53
+ requester.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
54
+ with pytest.raises(RequesterTimeoutException) as excInfo:
55
+ await requester.post(url='https://user:secret@example.com/v1/rpc?apiKey=secret', timeout=5)
56
+ exception = excInfo.value
57
+ assert (exception.method, exception.url, exception.timeoutSeconds, exception.timeoutType, exception.statusCode) == ('POST', 'https://example.com/v1/rpc', 5, 'ReadTimeout', 504)
58
+ assert exception.message.startswith('POST https://example.com/v1/rpc timed out after ')
59
+ assert 'secret' not in exception.message
60
+ assert isinstance(exception.__cause__, httpx.ReadTimeout)
File without changes
@@ -204,6 +204,18 @@ wheels = [
204
204
  { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
205
205
  ]
206
206
 
207
+ [[package]]
208
+ name = "apscheduler"
209
+ version = "3.11.3"
210
+ source = { registry = "https://pypi.org/simple" }
211
+ dependencies = [
212
+ { name = "tzlocal" },
213
+ ]
214
+ sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" }
215
+ wheels = [
216
+ { url = "https://files.pythonhosted.org/packages/42/c9/8638db32514dbb9157b3d82680c6faea89283523edf9ed2415ea3884f2ae/apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30", size = 66024, upload-time = "2026-06-28T19:39:20.982Z" },
217
+ ]
218
+
207
219
  [[package]]
208
220
  name = "ast-serialize"
209
221
  version = "0.11.2"
@@ -1499,6 +1511,9 @@ core-api = [
1499
1511
  { name = "starlette" },
1500
1512
  { name = "uvicorn", extra = ["standard"] },
1501
1513
  ]
1514
+ cron = [
1515
+ { name = "apscheduler" },
1516
+ ]
1502
1517
  database-psql = [
1503
1518
  { name = "asyncpg" },
1504
1519
  { name = "sqlalchemy", extra = ["asyncio"] },
@@ -1545,6 +1560,7 @@ requires-dist = [
1545
1560
  { name = "aiobotocore", marker = "extra == 'storage'", specifier = ">=3.9.1" },
1546
1561
  { name = "aiofiles", specifier = ">=25.1.0" },
1547
1562
  { name = "aiosqlite", marker = "extra == 'database-sqlite'", specifier = ">=0.22.1" },
1563
+ { name = "apscheduler", marker = "extra == 'cron'", specifier = ">=3.11.0,<4" },
1548
1564
  { name = "asyncpg", marker = "extra == 'database-psql'", specifier = ">=0.31.0" },
1549
1565
  { name = "azure-cosmos", marker = "extra == 'queue-cosmos'", specifier = ">=4.17.0" },
1550
1566
  { name = "azure-storage-queue", extras = ["aio"], marker = "extra == 'queue-aqs'", specifier = ">=12.17.0" },
@@ -1561,7 +1577,7 @@ requires-dist = [
1561
1577
  { name = "uvicorn", extras = ["standard"], marker = "extra == 'core-api'", specifier = ">=0.53.0" },
1562
1578
  { name = "web3", marker = "extra == 'web3'", specifier = ">=7.16.0,<8.0.0" },
1563
1579
  ]
1564
- provides-extras = ["api", "core-api", "storage", "queues", "queue-sqs", "queue-aqs", "queue-cosmos", "database-psql", "database-sqlite", "requester", "web3", "types"]
1580
+ provides-extras = ["api", "core-api", "storage", "queues", "queue-sqs", "queue-aqs", "queue-cosmos", "database-psql", "database-sqlite", "requester", "web3", "cron", "types"]
1565
1581
 
1566
1582
  [package.metadata.requires-dev]
1567
1583
  dev = [{ name = "kiba-build", specifier = "==0.1.11.dev15" }]
@@ -2995,6 +3011,27 @@ wheels = [
2995
3011
  { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
2996
3012
  ]
2997
3013
 
3014
+ [[package]]
3015
+ name = "tzdata"
3016
+ version = "2026.4"
3017
+ source = { registry = "https://pypi.org/simple" }
3018
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
3019
+ wheels = [
3020
+ { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
3021
+ ]
3022
+
3023
+ [[package]]
3024
+ name = "tzlocal"
3025
+ version = "5.4.4"
3026
+ source = { registry = "https://pypi.org/simple" }
3027
+ dependencies = [
3028
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
3029
+ ]
3030
+ sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" }
3031
+ wheels = [
3032
+ { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
3033
+ ]
3034
+
2998
3035
  [[package]]
2999
3036
  name = "urllib3"
3000
3037
  version = "2.8.0"