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