kiba-core 0.5.3.dev69__tar.gz → 0.5.3.dev71__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 (137) hide show
  1. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/CHANGELOG.md +3 -0
  2. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/PKG-INFO +3 -1
  3. kiba_core-0.5.3.dev71/core/cron/api_request_cron_job_processor.py +34 -0
  4. kiba_core-0.5.3.dev71/core/cron/cron_executor.py +68 -0
  5. kiba_core-0.5.3.dev71/core/cron/cron_job_processor.py +13 -0
  6. kiba_core-0.5.3.dev71/core/cron/model.py +19 -0
  7. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/logging.py +18 -8
  8. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/kiba_core.egg-info/PKG-INFO +3 -1
  9. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/kiba_core.egg-info/SOURCES.txt +8 -0
  10. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/kiba_core.egg-info/requires.txt +3 -0
  11. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/pyproject.toml +4 -1
  12. kiba_core-0.5.3.dev71/tests/cron/test_cron_executor.py +75 -0
  13. kiba_core-0.5.3.dev71/tests/requester/__init__.py +0 -0
  14. kiba_core-0.5.3.dev71/tests/test_logging.py +259 -0
  15. kiba_core-0.5.3.dev71/tests/web3/__init__.py +0 -0
  16. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/uv.lock +38 -1
  17. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/.github/pull_request_template.md +0 -0
  18. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/.github/workflows/deploy.yml +0 -0
  19. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/.github/workflows/pull-request.yml +0 -0
  20. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/.github/workflows/release.yml +0 -0
  21. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/.gitignore +0 -0
  22. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/Dockerfile +0 -0
  23. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/README.md +0 -0
  24. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/__init__.py +0 -0
  25. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/__init__.py +0 -0
  26. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/api_request.py +0 -0
  27. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/api_response.py +0 -0
  28. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/authorizer.py +0 -0
  29. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/default_routes.py +0 -0
  30. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/health.py +0 -0
  31. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/json_route.py +0 -0
  32. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/__init__.py +0 -0
  33. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/database_connection_middleware.py +0 -0
  34. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/exception_handling_middleware.py +0 -0
  35. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/logging_middleware.py +0 -0
  36. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/origin_ip_middleware.py +0 -0
  37. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/middleware/server_headers_middleware.py +0 -0
  38. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/openapi.py +0 -0
  39. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/rate_limit.py +0 -0
  40. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/request_context.py +0 -0
  41. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/route.py +0 -0
  42. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/route_auth.py +0 -0
  43. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/route_metadata.py +0 -0
  44. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/api/streaming_json_route.py +0 -0
  45. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/aws_requester.py +0 -0
  46. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/caching/__init__.py +0 -0
  47. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/caching/cache.py +0 -0
  48. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/caching/dict_cache.py +0 -0
  49. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/caching/file_cache.py +0 -0
  50. {kiba_core-0.5.3.dev69/core/locks → kiba_core-0.5.3.dev71/core/cron}/__init__.py +0 -0
  51. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/exceptions.py +0 -0
  52. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/http/__init__.py +0 -0
  53. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/http/basic_authentication.py +0 -0
  54. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/http/jwt.py +0 -0
  55. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/http/rest_method.py +0 -0
  56. {kiba_core-0.5.3.dev69/core/notifications → kiba_core-0.5.3.dev71/core/locks}/__init__.py +0 -0
  57. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/locks/lock.py +0 -0
  58. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/locks/model.py +0 -0
  59. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/locks/sql.py +0 -0
  60. {kiba_core-0.5.3.dev69/core/queues → kiba_core-0.5.3.dev71/core/notifications}/__init__.py +0 -0
  61. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/notifications/discord_client.py +0 -0
  62. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/notifications/notification_client.py +0 -0
  63. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/notifications/slack_client.py +0 -0
  64. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/py.typed +0 -0
  65. {kiba_core-0.5.3.dev69/core/store → kiba_core-0.5.3.dev71/core/queues}/__init__.py +0 -0
  66. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/aqs.py +0 -0
  67. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/cosmos.py +0 -0
  68. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/message_queue.py +0 -0
  69. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/message_queue_processor.py +0 -0
  70. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/model.py +0 -0
  71. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/sql.py +0 -0
  72. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/queues/sqs.py +0 -0
  73. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/requester/__init__.py +0 -0
  74. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/requester/requester.py +0 -0
  75. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/s3_manager.py +0 -0
  76. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/service_client.py +0 -0
  77. {kiba_core-0.5.3.dev69/core/util → kiba_core-0.5.3.dev71/core/store}/__init__.py +0 -0
  78. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/store/database.py +0 -0
  79. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/store/retriever.py +0 -0
  80. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/store/saver.py +0 -0
  81. {kiba_core-0.5.3.dev69/core/web3 → kiba_core-0.5.3.dev71/core/util}/__init__.py +0 -0
  82. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/async_util.py +0 -0
  83. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/chain_util.py +0 -0
  84. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/date_util.py +0 -0
  85. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/dict_util.py +0 -0
  86. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/file_util.py +0 -0
  87. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/hashing_util.py +0 -0
  88. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/http_util.py +0 -0
  89. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/json_util.py +0 -0
  90. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/list_util.py +0 -0
  91. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/process_util.py +0 -0
  92. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/string_util.py +0 -0
  93. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/typing_util.py +0 -0
  94. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/url_util.py +0 -0
  95. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/util/value_holder.py +0 -0
  96. {kiba_core-0.5.3.dev69/tests/requester → kiba_core-0.5.3.dev71/core/web3}/__init__.py +0 -0
  97. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/web3/eth_client.py +0 -0
  98. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/core/web3/multicall3.py +0 -0
  99. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/kiba_core.egg-info/dependency_links.txt +0 -0
  100. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/kiba_core.egg-info/top_level.txt +0 -0
  101. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/makefile +0 -0
  102. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/setup.cfg +0 -0
  103. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/__init__.py +0 -0
  104. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_api_response.py +0 -0
  105. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_authorizer.py +0 -0
  106. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_exception_handling_middleware.py +0 -0
  107. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_json_route.py +0 -0
  108. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_openapi.py +0 -0
  109. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_origin_ip_middleware.py +0 -0
  110. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_rate_limit.py +0 -0
  111. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_request_context.py +0 -0
  112. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_route.py +0 -0
  113. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/api/test_streaming_json_route.py +0 -0
  114. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/caching/test_dict_cache.py +0 -0
  115. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/caching/test_file_cache.py +0 -0
  116. {kiba_core-0.5.3.dev69/tests/web3 → kiba_core-0.5.3.dev71/tests/cron}/__init__.py +0 -0
  117. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/locks/test_sql_lock.py +0 -0
  118. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/queues/conftest.py +0 -0
  119. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/queues/test_aqs_queue.py +0 -0
  120. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/queues/test_cosmos_queue.py +0 -0
  121. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/queues/test_sql_queue.py +0 -0
  122. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/queues/test_sqs_queue.py +0 -0
  123. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/requester/test_requester.py +0 -0
  124. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/test_database.py +0 -0
  125. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/test_exceptions.py +0 -0
  126. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/__init__.py +0 -0
  127. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_async_util.py +0 -0
  128. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_chain_util.py +0 -0
  129. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_date_util.py +0 -0
  130. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_dict_util.py +0 -0
  131. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_file_util.py +0 -0
  132. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_json_util.py +0 -0
  133. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_list_util.py +0 -0
  134. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_process_util.py +0 -0
  135. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_string_util.py +0 -0
  136. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/util/test_url_util.py +0 -0
  137. {kiba_core-0.5.3.dev69 → kiba_core-0.5.3.dev71}/tests/web3/test_rest_eth_client.py +0 -0
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [PATCH] Fixed log records from `logging.info`, `warning`, `error`, `critical`, `debug`, `exception`, `stat` and `api` recording their location as `core/logging.py` instead of the calling code; this was only visible in `init_logging`'s text format, since the JSON and basic formats don't output the location
12
+ - [MINOR] Updated `init_logging`, `init_json_logging` and `init_basic_logging` to set the loggers of the third-party libraries core uses (`DEFAULT_EXTERNAL_LOGGER_NAMES`: httpx2, httpcore2, sqlalchemy, aiosqlite, asyncpg, botocore, aiobotocore, azure, apscheduler, web3) to `WARNING`; pass `extraLoggerNames` to quieten more loggers on top of these, or `loggerNames` to replace them (`loggerNames=[]` keeps the previous behaviour). `init_external_loggers` takes the same arguments and now defaults to the same list
13
+ - [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
11
14
  - [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
12
15
  - [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
13
16
  - [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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev69
3
+ Version: 0.5.3.dev71
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 {}
@@ -120,36 +120,43 @@ def init_logger(logger: Logger, loggingLevel: int, handler: StreamHandler) -> No
120
120
  logger.setLevel(level=loggingLevel)
121
121
 
122
122
 
123
- def init_external_loggers(loggerNames: Collection[str], loggingLevel: int = logging.WARNING) -> None:
124
- for loggerName in loggerNames:
123
+ # NOTE(krishan711): third-party libraries pulled in by core's extras; their info-level logs drown out the app's own
124
+ DEFAULT_EXTERNAL_LOGGER_NAMES = ('httpx2', 'httpcore2', 'sqlalchemy', 'aiosqlite', 'asyncpg', 'botocore', 'aiobotocore', 'azure', 'apscheduler', 'web3')
125
+
126
+
127
+ def init_external_loggers(loggerNames: Collection[str] | None = None, extraLoggerNames: Collection[str] | None = None, loggingLevel: int = logging.WARNING) -> None:
128
+ for loggerName in {*(loggerNames if loggerNames is not None else DEFAULT_EXTERNAL_LOGGER_NAMES), *(extraLoggerNames or [])}:
125
129
  logging.getLogger(loggerName).setLevel(loggingLevel)
126
130
 
127
131
 
128
- def init_logging(name: str, version: str, environment: str, showDebug: bool = False, requestIdHolder: RequestIdHolder | None = None) -> None:
132
+ def init_logging(name: str, version: str, environment: str, showDebug: bool = False, requestIdHolder: RequestIdHolder | None = None, loggerNames: Collection[str] | None = None, extraLoggerNames: Collection[str] | None = None) -> None:
129
133
  loggingLevel = logging.DEBUG if showDebug else logging.INFO
130
134
  for logFormat in ALL_LOGGER_FORMATS:
131
135
  logger = logging.getLogger(name=logFormat.loggerType)
132
136
  handler = StreamHandler(stream=sys.stdout)
133
137
  handler.setFormatter(fmt=KibaLoggingFormatter(logFormat=logFormat, name=name, version=version, environment=environment, requestIdHolder=requestIdHolder))
134
138
  init_logger(logger=logger, loggingLevel=loggingLevel, handler=handler)
139
+ init_external_loggers(loggerNames=loggerNames, extraLoggerNames=extraLoggerNames)
135
140
 
136
141
 
137
- def init_json_logging(name: str, version: str, environment: str, showDebug: bool = False, requestIdHolder: RequestIdHolder | None = None) -> None:
142
+ def init_json_logging(name: str, version: str, environment: str, showDebug: bool = False, requestIdHolder: RequestIdHolder | None = None, loggerNames: Collection[str] | None = None, extraLoggerNames: Collection[str] | None = None) -> None:
138
143
  loggingLevel = logging.DEBUG if showDebug else logging.INFO
139
144
  for logFormat in ALL_LOGGER_FORMATS:
140
145
  logger = logging.getLogger(name=logFormat.loggerType)
141
146
  handler = StreamHandler(stream=sys.stdout)
142
147
  handler.setFormatter(fmt=KibaJsonLoggingFormatter(logFormat=logFormat, name=name, version=version, environment=environment, requestIdHolder=requestIdHolder))
143
148
  init_logger(logger=logger, loggingLevel=loggingLevel, handler=handler)
149
+ init_external_loggers(loggerNames=loggerNames, extraLoggerNames=extraLoggerNames)
144
150
 
145
151
 
146
- def init_basic_logging(showDebug: bool = False) -> None:
152
+ def init_basic_logging(showDebug: bool = False, loggerNames: Collection[str] | None = None, extraLoggerNames: Collection[str] | None = None) -> None:
147
153
  loggingLevel = logging.DEBUG if showDebug else logging.INFO
148
154
  for logFormat in ALL_LOGGER_FORMATS:
149
155
  logger = logging.getLogger(name=logFormat.loggerType)
150
156
  handler = StreamHandler(stream=sys.stdout)
151
157
  handler.setFormatter(fmt=Formatter(fmt=logFormat.loggerFormat))
152
158
  init_logger(logger=logger, loggingLevel=loggingLevel, handler=handler)
159
+ init_external_loggers(loggerNames=loggerNames, extraLoggerNames=extraLoggerNames)
153
160
 
154
161
 
155
162
  def _serialize_numeric_value(value: Union[float, None]) -> str:
@@ -168,7 +175,8 @@ def stat(name: str, key: str, value: float = 1) -> None:
168
175
  nameValue = _serialize_string_value(value=str(name))
169
176
  keyValue = _serialize_string_value(value=str(key))
170
177
  statValue = _serialize_numeric_value(value=value)
171
- STAT_LOGGER.log(level=logging.INFO, msg='', extra={'statName': nameValue, 'statKey': keyValue, 'statValue': statValue})
178
+ # NOTE(krishan711): stacklevel=2 skips this function so records point at the caller
179
+ STAT_LOGGER.log(level=logging.INFO, msg='', extra={'statName': nameValue, 'statKey': keyValue, 'statValue': statValue}, stacklevel=2)
172
180
 
173
181
 
174
182
  # TODO(krishan711): make pathPattern mandatory in next major release
@@ -184,6 +192,7 @@ def api(action: str, path: str, query: str, pathPattern: str | None = None, resp
184
192
  level=logging.INFO,
185
193
  msg='',
186
194
  extra={'apiAction': actionString, 'apiPath': pathString, 'apiPathPattern': pathPatternString, 'apiQuery': queryString, 'apiResponse': responseString or '', 'apiDuration': durationString or ''},
195
+ stacklevel=2,
187
196
  )
188
197
 
189
198
 
@@ -197,9 +206,10 @@ INFO = logging.INFO
197
206
  DEBUG = logging.DEBUG
198
207
 
199
208
 
200
- def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
209
+ def _log(level: int, msg: str, *args: Any, stacklevel: int = 1, **kwargs: Any) -> None: # type: ignore[explicit-any]
201
210
  if ROOT_LOGGER.isEnabledFor(level=level):
202
- ROOT_LOGGER._log(level=level, msg=msg, args=args, **kwargs) # noqa: SLF001
211
+ # NOTE(krishan711): +2 skips this function and the public wrapper (info, error, ...) so records point at the caller
212
+ ROOT_LOGGER._log(level=level, msg=msg, args=args, stacklevel=stacklevel + 2, **kwargs) # noqa: SLF001
203
213
 
204
214
 
205
215
  def critical(msg: str, *args: Any, **kwargs: Any) -> None: # type: ignore[explicit-any]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev69
3
+ Version: 0.5.3.dev71
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
@@ -92,6 +97,7 @@ kiba_core.egg-info/top_level.txt
92
97
  tests/__init__.py
93
98
  tests/test_database.py
94
99
  tests/test_exceptions.py
100
+ tests/test_logging.py
95
101
  tests/api/test_api_response.py
96
102
  tests/api/test_authorizer.py
97
103
  tests/api/test_exception_handling_middleware.py
@@ -104,6 +110,8 @@ tests/api/test_route.py
104
110
  tests/api/test_streaming_json_route.py
105
111
  tests/caching/test_dict_cache.py
106
112
  tests/caching/test_file_cache.py
113
+ tests/cron/__init__.py
114
+ tests/cron/test_cron_executor.py
107
115
  tests/locks/test_sql_lock.py
108
116
  tests/queues/conftest.py
109
117
  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.dev69"
3
+ version = "0.5.3.dev71"
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