kiba-core 0.5.3.dev70__tar.gz → 0.5.3.dev72__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 (139) hide show
  1. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/CHANGELOG.md +6 -0
  2. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/PKG-INFO +1 -1
  3. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/logging.py +18 -8
  4. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/web3/eth_client.py +67 -33
  5. kiba_core-0.5.3.dev72/core/web3/eth_client_registry.py +46 -0
  6. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/kiba_core.egg-info/PKG-INFO +1 -1
  7. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/kiba_core.egg-info/SOURCES.txt +3 -0
  8. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/pyproject.toml +1 -1
  9. kiba_core-0.5.3.dev72/tests/test_logging.py +259 -0
  10. kiba_core-0.5.3.dev72/tests/web3/test_eth_client_registry.py +63 -0
  11. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/web3/test_rest_eth_client.py +80 -2
  12. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/.github/pull_request_template.md +0 -0
  13. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/.github/workflows/deploy.yml +0 -0
  14. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/.github/workflows/pull-request.yml +0 -0
  15. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/.github/workflows/release.yml +0 -0
  16. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/.gitignore +0 -0
  17. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/Dockerfile +0 -0
  18. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/README.md +0 -0
  19. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/__init__.py +0 -0
  20. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/__init__.py +0 -0
  21. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/api_request.py +0 -0
  22. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/api_response.py +0 -0
  23. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/authorizer.py +0 -0
  24. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/default_routes.py +0 -0
  25. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/health.py +0 -0
  26. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/json_route.py +0 -0
  27. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/__init__.py +0 -0
  28. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/database_connection_middleware.py +0 -0
  29. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/exception_handling_middleware.py +0 -0
  30. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/logging_middleware.py +0 -0
  31. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/origin_ip_middleware.py +0 -0
  32. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/middleware/server_headers_middleware.py +0 -0
  33. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/openapi.py +0 -0
  34. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/rate_limit.py +0 -0
  35. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/request_context.py +0 -0
  36. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/route.py +0 -0
  37. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/route_auth.py +0 -0
  38. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/route_metadata.py +0 -0
  39. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/api/streaming_json_route.py +0 -0
  40. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/aws_requester.py +0 -0
  41. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/caching/__init__.py +0 -0
  42. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/caching/cache.py +0 -0
  43. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/caching/dict_cache.py +0 -0
  44. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/caching/file_cache.py +0 -0
  45. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/cron/__init__.py +0 -0
  46. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/cron/api_request_cron_job_processor.py +0 -0
  47. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/cron/cron_executor.py +0 -0
  48. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/cron/cron_job_processor.py +0 -0
  49. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/cron/model.py +0 -0
  50. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/exceptions.py +0 -0
  51. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/http/__init__.py +0 -0
  52. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/http/basic_authentication.py +0 -0
  53. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/http/jwt.py +0 -0
  54. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/http/rest_method.py +0 -0
  55. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/locks/__init__.py +0 -0
  56. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/locks/lock.py +0 -0
  57. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/locks/model.py +0 -0
  58. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/locks/sql.py +0 -0
  59. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/notifications/__init__.py +0 -0
  60. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/notifications/discord_client.py +0 -0
  61. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/notifications/notification_client.py +0 -0
  62. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/notifications/slack_client.py +0 -0
  63. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/py.typed +0 -0
  64. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/__init__.py +0 -0
  65. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/aqs.py +0 -0
  66. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/cosmos.py +0 -0
  67. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/message_queue.py +0 -0
  68. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/message_queue_processor.py +0 -0
  69. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/model.py +0 -0
  70. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/sql.py +0 -0
  71. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/queues/sqs.py +0 -0
  72. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/requester/__init__.py +0 -0
  73. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/requester/requester.py +0 -0
  74. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/s3_manager.py +0 -0
  75. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/service_client.py +0 -0
  76. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/store/__init__.py +0 -0
  77. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/store/database.py +0 -0
  78. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/store/retriever.py +0 -0
  79. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/store/saver.py +0 -0
  80. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/__init__.py +0 -0
  81. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/async_util.py +0 -0
  82. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/chain_util.py +0 -0
  83. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/date_util.py +0 -0
  84. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/dict_util.py +0 -0
  85. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/file_util.py +0 -0
  86. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/hashing_util.py +0 -0
  87. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/http_util.py +0 -0
  88. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/json_util.py +0 -0
  89. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/list_util.py +0 -0
  90. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/process_util.py +0 -0
  91. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/string_util.py +0 -0
  92. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/typing_util.py +0 -0
  93. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/url_util.py +0 -0
  94. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/util/value_holder.py +0 -0
  95. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/web3/__init__.py +0 -0
  96. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/core/web3/multicall3.py +0 -0
  97. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/kiba_core.egg-info/dependency_links.txt +0 -0
  98. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/kiba_core.egg-info/requires.txt +0 -0
  99. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/kiba_core.egg-info/top_level.txt +0 -0
  100. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/makefile +0 -0
  101. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/setup.cfg +0 -0
  102. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/__init__.py +0 -0
  103. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_api_response.py +0 -0
  104. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_authorizer.py +0 -0
  105. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_exception_handling_middleware.py +0 -0
  106. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_json_route.py +0 -0
  107. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_openapi.py +0 -0
  108. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_origin_ip_middleware.py +0 -0
  109. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_rate_limit.py +0 -0
  110. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_request_context.py +0 -0
  111. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_route.py +0 -0
  112. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/api/test_streaming_json_route.py +0 -0
  113. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/caching/test_dict_cache.py +0 -0
  114. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/caching/test_file_cache.py +0 -0
  115. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/cron/__init__.py +0 -0
  116. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/cron/test_cron_executor.py +0 -0
  117. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/locks/test_sql_lock.py +0 -0
  118. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/queues/conftest.py +0 -0
  119. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/queues/test_aqs_queue.py +0 -0
  120. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/queues/test_cosmos_queue.py +0 -0
  121. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/queues/test_sql_queue.py +0 -0
  122. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/queues/test_sqs_queue.py +0 -0
  123. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/requester/__init__.py +0 -0
  124. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/requester/test_requester.py +0 -0
  125. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/test_database.py +0 -0
  126. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/test_exceptions.py +0 -0
  127. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/__init__.py +0 -0
  128. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_async_util.py +0 -0
  129. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_chain_util.py +0 -0
  130. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_date_util.py +0 -0
  131. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_dict_util.py +0 -0
  132. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_file_util.py +0 -0
  133. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_json_util.py +0 -0
  134. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_list_util.py +0 -0
  135. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_process_util.py +0 -0
  136. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_string_util.py +0 -0
  137. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/util/test_url_util.py +0 -0
  138. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/tests/web3/__init__.py +0 -0
  139. {kiba_core-0.5.3.dev70 → kiba_core-0.5.3.dev72}/uv.lock +0 -0
@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - [MAJOR] Updated `RestEthClient` to raise `RpcServerException` (a `ServerException` keeping the original status code and message, with the original exception as its cause) when the node responds with a 5xx, so callers can tell node failures apart from other server errors; `RequesterTimeoutException` is still raised unchanged. Code catching `InternalServerErrorException` (or `BadGatewayException`, `ServiceUnavailableException`, …) around `RestEthClient` calls must catch `RpcServerException` instead
12
+ - [MINOR] Added `RestEthClient.get_code`
13
+ - [MINOR] Added optional `maxConcurrentRequestCount` and `semaphore` to `RestEthClient` to limit concurrent requests; pass the same `semaphore` to several clients to limit them together (it takes precedence over `maxConcurrentRequestCount`)
14
+ - [MINOR] Added `EthClientRegistry` to register regular, archive and paymaster `RestEthClient`s per chain and pick the archive client for dates older than `archiveThresholdDays` (default 15)
15
+ - [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
16
+ - [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
11
17
  - [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
18
  - [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
13
19
  - [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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev70
3
+ Version: 0.5.3.dev72
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
@@ -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,4 +1,5 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import typing
3
4
  from typing import Any
4
5
 
@@ -24,8 +25,11 @@ from core.exceptions import BadRequestException
24
25
  from core.exceptions import ClientException
25
26
  from core.exceptions import KibaException
26
27
  from core.exceptions import NotFoundException
28
+ from core.exceptions import ServerException
27
29
  from core.exceptions import TooManyRequestsException
28
30
  from core.requester import Requester
31
+ from core.requester.requester import RequesterTimeoutException
32
+ from core.requester.requester import ResponseException
29
33
  from core.util import chain_util
30
34
  from core.util import json_util
31
35
  from core.util.typing_util import JsonObject
@@ -61,6 +65,11 @@ class TransactionFailedException(KibaException):
61
65
  return output
62
66
 
63
67
 
68
+ class RpcServerException(ServerException):
69
+ def __init__(self, message: str | None = None, statusCode: int = 500) -> None:
70
+ super().__init__(message=message or 'RPC Server Error', statusCode=statusCode)
71
+
72
+
64
73
  class EthClientInterface:
65
74
  def __init__(self, web3Connection: Web3, chainId: int, isTestnet: bool = False) -> None:
66
75
  self.w3 = web3Connection
@@ -297,12 +306,26 @@ class Web3EthClient(EthClientInterface):
297
306
 
298
307
  class RestEthClient(EthClientInterface):
299
308
  # NOTE(krishan711): find docs at https://eth.wiki/json-rpc/API
300
- def __init__(self, url: str, requester: Requester, chainId: int, isTestnet: bool = False, shouldBackoffRetryOnRateLimit: bool = True, retryLimit: int = 10) -> None:
309
+ def __init__(
310
+ self,
311
+ url: str,
312
+ requester: Requester,
313
+ chainId: int,
314
+ isTestnet: bool = False,
315
+ shouldBackoffRetryOnRateLimit: bool = True,
316
+ retryLimit: int = 10,
317
+ maxConcurrentRequestCount: int | None = None,
318
+ semaphore: asyncio.Semaphore | None = None,
319
+ ) -> None:
301
320
  super().__init__(web3Connection=Web3(), chainId=chainId, isTestnet=isTestnet)
302
321
  self.url = url
303
322
  self.requester = requester
304
323
  self.shouldBackoffRetryOnRateLimit = shouldBackoffRetryOnRateLimit
305
324
  self.retryLimit = retryLimit
325
+ # NOTE(krishan711): pass the same semaphore to several clients to cap their combined concurrent requests, or set maxConcurrentRequestCount to cap just this client
326
+ if semaphore is None and maxConcurrentRequestCount is not None:
327
+ semaphore = asyncio.Semaphore(maxConcurrentRequestCount)
328
+ self.semaphore = semaphore
306
329
  self.w3 = Web3()
307
330
 
308
331
  @staticmethod
@@ -310,39 +333,45 @@ class RestEthClient(EthClientInterface):
310
333
  return int(value, 16)
311
334
 
312
335
  async def _make_request(self, method: str, params: ListAny | None = None) -> DictStrAny:
313
- retryCount = 0
314
- initialBackoffSeconds = 1.0
315
- responseDict: DictStrAny = {}
316
- while True:
317
- try:
318
- response = await self.requester.post_json(
319
- url=self.url,
320
- dataDict={'jsonrpc': '2.0', 'method': method, 'params': params or [], 'id': None},
321
- timeout=10,
322
- )
323
- responseDict = response.json()
324
- if responseDict.get('error'):
325
- errorMessage = responseDict['error'].get('message') or responseDict['error'].get('details') or json_util.dumps(responseDict['error'])
326
- raise BadRequestException(message=errorMessage)
327
- except ClientException as exception:
328
- # NOTE(krishan711): this is just here to debug 429s from coinbase, remove when done
329
- logging.info(f'caught exception on _make_request: {exception!s}')
330
- exceptionMessage = exception.message or ''
331
- if (
332
- isinstance(exception, TooManyRequestsException)
333
- # NOTE(krishan711): sometimes coinbase returns an error message with 200 status
334
- or 'over rate limit' in exceptionMessage
335
- or '429 Too Many Requests' in exceptionMessage
336
- ):
337
- if not self.shouldBackoffRetryOnRateLimit or retryCount >= self.retryLimit:
336
+ async with self.semaphore or contextlib.nullcontext():
337
+ retryCount = 0
338
+ initialBackoffSeconds = 1.0
339
+ responseDict: DictStrAny = {}
340
+ while True:
341
+ try:
342
+ response = await self.requester.post_json(
343
+ url=self.url,
344
+ dataDict={'jsonrpc': '2.0', 'method': method, 'params': params or [], 'id': None},
345
+ timeout=10,
346
+ )
347
+ responseDict = response.json()
348
+ if responseDict.get('error'):
349
+ errorMessage = responseDict['error'].get('message') or responseDict['error'].get('details') or json_util.dumps(responseDict['error'])
350
+ raise BadRequestException(message=errorMessage)
351
+ except ClientException as exception:
352
+ # NOTE(krishan711): this is just here to debug 429s from coinbase, remove when done
353
+ logging.info(f'caught exception on _make_request: {exception!s}')
354
+ exceptionMessage = exception.message or ''
355
+ if (
356
+ isinstance(exception, TooManyRequestsException)
357
+ # NOTE(krishan711): sometimes coinbase returns an error message with 200 status
358
+ or 'over rate limit' in exceptionMessage
359
+ or '429 Too Many Requests' in exceptionMessage
360
+ ):
361
+ if not self.shouldBackoffRetryOnRateLimit or retryCount >= self.retryLimit:
362
+ raise
363
+ retryCount += 1
364
+ exponentialBackoffSeconds = initialBackoffSeconds * (2 ** (retryCount - 1))
365
+ logging.info(f'Retrying {method} after {exponentialBackoffSeconds} seconds due to rate limit exceeded: {exception!s}')
366
+ await asyncio.sleep(exponentialBackoffSeconds)
367
+ continue
368
+ raise
369
+ except (ServerException, ResponseException) as exception:
370
+ # NOTE(krishan711): timeouts are raised from the requester rather than the node so they keep their own type
371
+ if isinstance(exception, RequesterTimeoutException) or exception.statusCode < 500: # noqa: PLR2004
338
372
  raise
339
- retryCount += 1
340
- exponentialBackoffSeconds = initialBackoffSeconds * (2 ** (retryCount - 1))
341
- logging.info(f'Retrying {method} after {exponentialBackoffSeconds} seconds due to rate limit exceeded: {exception!s}')
342
- await asyncio.sleep(exponentialBackoffSeconds)
343
- continue
344
- raise
345
- return responseDict
373
+ raise RpcServerException(message=exception.message, statusCode=exception.statusCode) from exception
374
+ return responseDict
346
375
 
347
376
  async def get_latest_block_number(self) -> int:
348
377
  response = await self._make_request(method='eth_blockNumber')
@@ -394,6 +423,10 @@ class RestEthClient(EthClientInterface):
394
423
  raise NotFoundException
395
424
  return typing.cast(TxReceipt, method_formatters.PYTHONIC_RESULT_FORMATTERS[RPC.eth_getTransactionReceipt](response['result']))
396
425
 
426
+ async def get_code(self, address: str, blockNumber: int | None = None) -> str:
427
+ response = await self._make_request(method='eth_getCode', params=[address, hex(blockNumber) if blockNumber is not None else 'latest'])
428
+ return str(response.get('result', '0x'))
429
+
397
430
  async def get_log_entries(
398
431
  self,
399
432
  topics: list[str] | None = None,
@@ -481,3 +514,4 @@ class RestEthClient(EthClientInterface):
481
514
  transactionData = '0x' + transactionData
482
515
  response = await self._make_request(method='eth_sendRawTransaction', params=[transactionData])
483
516
  return typing.cast(str, response['result'])
517
+
@@ -0,0 +1,46 @@
1
+ import datetime
2
+
3
+ from core.exceptions import InternalServerErrorException
4
+ from core.util import date_util
5
+ from core.web3.eth_client import RestEthClient
6
+
7
+
8
+ class EthClientRegistry:
9
+ def __init__(self, archiveThresholdDays: int = 15) -> None:
10
+ self.archiveThresholdDays = archiveThresholdDays
11
+ self.ethClientsByChainId: dict[int, RestEthClient] = {}
12
+ self.archiveEthClientsByChainId: dict[int, RestEthClient] = {}
13
+ self.paymasterEthClientsByChainId: dict[int, RestEthClient] = {}
14
+
15
+ def register_client(self, client: RestEthClient) -> None:
16
+ self.ethClientsByChainId[client.chainId] = client
17
+
18
+ def register_archive_client(self, client: RestEthClient) -> None:
19
+ self.archiveEthClientsByChainId[client.chainId] = client
20
+
21
+ def register_paymaster_client(self, client: RestEthClient) -> None:
22
+ self.paymasterEthClientsByChainId[client.chainId] = client
23
+
24
+ def get_client(self, chainId: int, date: datetime.date | None = None) -> RestEthClient:
25
+ # NOTE(krishan711): regular nodes may have pruned state older than the threshold so those reads go to the archive node
26
+ dateDiff = date_util.calculate_diff_days(startDate=date_util.datetime_from_date(date=date), endDate=date_util.datetime_from_now()) if date is not None else 0
27
+ if dateDiff > self.archiveThresholdDays:
28
+ return self.get_archive_client(chainId=chainId)
29
+ return self.get_regular_client(chainId=chainId)
30
+
31
+ def get_paymaster_client(self, chainId: int) -> RestEthClient:
32
+ if chainId not in self.paymasterEthClientsByChainId:
33
+ raise InternalServerErrorException(f'Chain {chainId} does not have a paymaster client')
34
+ return self.paymasterEthClientsByChainId[chainId]
35
+
36
+ def get_archive_client(self, chainId: int) -> RestEthClient:
37
+ if chainId not in self.archiveEthClientsByChainId:
38
+ raise InternalServerErrorException(f'Chain {chainId} does not have an archive client')
39
+ return self.archiveEthClientsByChainId[chainId]
40
+
41
+ def get_regular_client(self, chainId: int) -> RestEthClient:
42
+ if chainId in self.ethClientsByChainId:
43
+ return self.ethClientsByChainId[chainId]
44
+ if chainId in self.archiveEthClientsByChainId:
45
+ return self.archiveEthClientsByChainId[chainId]
46
+ raise InternalServerErrorException(f'Chain {chainId} does not have a regular client or an archive client')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiba-core
3
- Version: 0.5.3.dev70
3
+ Version: 0.5.3.dev72
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
@@ -88,6 +88,7 @@ core/util/url_util.py
88
88
  core/util/value_holder.py
89
89
  core/web3/__init__.py
90
90
  core/web3/eth_client.py
91
+ core/web3/eth_client_registry.py
91
92
  core/web3/multicall3.py
92
93
  kiba_core.egg-info/PKG-INFO
93
94
  kiba_core.egg-info/SOURCES.txt
@@ -97,6 +98,7 @@ kiba_core.egg-info/top_level.txt
97
98
  tests/__init__.py
98
99
  tests/test_database.py
99
100
  tests/test_exceptions.py
101
+ tests/test_logging.py
100
102
  tests/api/test_api_response.py
101
103
  tests/api/test_authorizer.py
102
104
  tests/api/test_exception_handling_middleware.py
@@ -131,4 +133,5 @@ tests/util/test_process_util.py
131
133
  tests/util/test_string_util.py
132
134
  tests/util/test_url_util.py
133
135
  tests/web3/__init__.py
136
+ tests/web3/test_eth_client_registry.py
134
137
  tests/web3/test_rest_eth_client.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiba-core"
3
- version = "0.5.3.dev70"
3
+ version = "0.5.3.dev72"
4
4
  description = "Kiba Labs' python utilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12, <4"
@@ -0,0 +1,259 @@
1
+ import datetime
2
+ import json
3
+ import logging as pythonLogging
4
+ from collections.abc import Iterator
5
+
6
+ import pytest
7
+
8
+ from core import logging
9
+ from core.exceptions import NotFoundException
10
+ from core.util.value_holder import RequestIdHolder
11
+
12
+ APP_LOGGER_NAME = 'test-app-library'
13
+ CORE_LOGGER_NAMES = [logFormat.loggerType for logFormat in logging.ALL_LOGGER_FORMATS]
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def restore_logging_state() -> Iterator[None]:
18
+ externalLoggerNames = [*logging.DEFAULT_EXTERNAL_LOGGER_NAMES, APP_LOGGER_NAME]
19
+ originalExternalLevels = {loggerName: pythonLogging.getLogger(loggerName).level for loggerName in externalLoggerNames}
20
+ originalCoreState = {loggerName: (pythonLogging.getLogger(loggerName).handlers, pythonLogging.getLogger(loggerName).level, pythonLogging.getLogger(loggerName).propagate) for loggerName in CORE_LOGGER_NAMES}
21
+ for loggerName in externalLoggerNames:
22
+ pythonLogging.getLogger(loggerName).setLevel(pythonLogging.NOTSET)
23
+ yield
24
+ for loggerName, level in originalExternalLevels.items():
25
+ pythonLogging.getLogger(loggerName).setLevel(level)
26
+ for loggerName, (handlers, level, propagate) in originalCoreState.items():
27
+ logger = pythonLogging.getLogger(loggerName)
28
+ logger.handlers, logger.propagate = handlers, propagate
29
+ logger.setLevel(level)
30
+
31
+
32
+ def _levels(loggerNames: list[str]) -> list[int]:
33
+ return [pythonLogging.getLogger(loggerName).level for loggerName in loggerNames]
34
+
35
+
36
+ def _lines(capsys: pytest.CaptureFixture[str]) -> list[str]:
37
+ return capsys.readouterr().out.splitlines()
38
+
39
+
40
+ def _json_lines(capsys: pytest.CaptureFixture[str]) -> list[dict[str, object]]:
41
+ return [json.loads(line) for line in _lines(capsys)]
42
+
43
+
44
+ class TestExternalLoggers:
45
+ def test_init_quietens_core_external_loggers_by_default(self) -> None:
46
+ logging.init_basic_logging()
47
+ assert _levels(['httpx2', 'sqlalchemy', APP_LOGGER_NAME]) == [pythonLogging.WARNING, pythonLogging.WARNING, pythonLogging.NOTSET]
48
+
49
+ def test_extra_logger_names_are_added_to_the_defaults(self) -> None:
50
+ logging.init_basic_logging(extraLoggerNames=[APP_LOGGER_NAME])
51
+ assert _levels(['httpx2', APP_LOGGER_NAME]) == [pythonLogging.WARNING, pythonLogging.WARNING]
52
+
53
+ def test_logger_names_replace_the_defaults(self) -> None:
54
+ logging.init_basic_logging(loggerNames=[APP_LOGGER_NAME])
55
+ assert _levels(['httpx2', APP_LOGGER_NAME]) == [pythonLogging.NOTSET, pythonLogging.WARNING]
56
+
57
+ def test_every_init_function_applies_the_logger_names(self) -> None:
58
+ logging.init_logging(name='app', version='v1', environment='test', extraLoggerNames=[APP_LOGGER_NAME])
59
+ assert _levels(['httpx2', APP_LOGGER_NAME]) == [pythonLogging.WARNING, pythonLogging.WARNING]
60
+ pythonLogging.getLogger(APP_LOGGER_NAME).setLevel(pythonLogging.NOTSET)
61
+ logging.init_json_logging(name='app', version='v1', environment='test', loggerNames=[APP_LOGGER_NAME])
62
+ assert pythonLogging.getLogger(APP_LOGGER_NAME).level == pythonLogging.WARNING
63
+
64
+ def test_init_external_loggers_uses_the_given_level(self) -> None:
65
+ logging.init_external_loggers(extraLoggerNames=[APP_LOGGER_NAME], loggingLevel=pythonLogging.ERROR)
66
+ assert _levels(['httpx2', APP_LOGGER_NAME]) == [pythonLogging.ERROR, pythonLogging.ERROR]
67
+
68
+ def test_external_logger_level_applies_to_child_loggers(self, capsys: pytest.CaptureFixture[str]) -> None:
69
+ logging.init_basic_logging()
70
+ pythonLogging.getLogger('sqlalchemy.engine').info('noisy query log')
71
+ pythonLogging.getLogger('sqlalchemy.engine').warning('important warning')
72
+ assert _lines(capsys) == ['important warning']
73
+
74
+
75
+ class TestBasicLogging:
76
+ def test_logs_info_and_above_but_not_debug(self, capsys: pytest.CaptureFixture[str]) -> None:
77
+ logging.init_basic_logging()
78
+ logging.debug('debug line')
79
+ logging.info('info line')
80
+ logging.warning('warning line')
81
+ logging.error('error line')
82
+ logging.critical('critical line')
83
+ assert _lines(capsys) == ['info line', 'warning line', 'error line', 'critical line']
84
+
85
+ def test_show_debug_includes_debug(self, capsys: pytest.CaptureFixture[str]) -> None:
86
+ logging.init_basic_logging(showDebug=True)
87
+ logging.debug('debug line')
88
+ assert _lines(capsys) == ['debug line']
89
+
90
+ def test_messages_are_formatted_with_args(self, capsys: pytest.CaptureFixture[str]) -> None:
91
+ logging.init_basic_logging()
92
+ logging.info('hello %s, you are %d', 'kiba', 3)
93
+ assert _lines(capsys) == ['hello kiba, you are 3']
94
+
95
+ def test_core_loggers_do_not_propagate_to_avoid_duplicate_output(self, capsys: pytest.CaptureFixture[str]) -> None:
96
+ logging.init_basic_logging()
97
+ logging.init_basic_logging()
98
+ logging.stat(name='events', key='created')
99
+ assert _lines(capsys) == ['events:created:1']
100
+
101
+
102
+ class TestStat:
103
+ def test_logs_name_key_and_value(self, capsys: pytest.CaptureFixture[str]) -> None:
104
+ logging.init_basic_logging()
105
+ logging.stat(name='events', key='created', value=2.5)
106
+ assert _lines(capsys) == ['events:created:2.5']
107
+
108
+ def test_escapes_separators_and_rounds_values(self, capsys: pytest.CaptureFixture[str]) -> None:
109
+ logging.init_basic_logging()
110
+ logging.stat(name='db:query', key='user:read', value=1.23456789)
111
+ logging.stat(name='whole', key='number', value=3.0)
112
+ assert _lines(capsys) == ['db__query:user__read:1.234568', 'whole:number:3']
113
+
114
+ def test_nothing_is_logged_when_the_stat_logger_is_disabled(self, capsys: pytest.CaptureFixture[str]) -> None:
115
+ logging.init_basic_logging()
116
+ logging.STAT_LOGGER.setLevel(pythonLogging.WARNING)
117
+ logging.stat(name='events', key='created')
118
+ assert _lines(capsys) == []
119
+
120
+
121
+ class TestApi:
122
+ def test_logs_action_path_query_response_and_duration(self, capsys: pytest.CaptureFixture[str]) -> None:
123
+ logging.init_basic_logging()
124
+ logging.api(action='GET', path='/v1/users', query='limit=10', response=200, duration=0.25)
125
+ assert _lines(capsys) == ['GET:/v1/users:limit=10:200:0.25']
126
+
127
+ def test_missing_response_and_duration_are_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
128
+ logging.init_basic_logging()
129
+ logging.api(action='CRON', path='job', query='')
130
+ assert _lines(capsys) == ['CRON:job:::']
131
+
132
+ def test_escapes_separators(self, capsys: pytest.CaptureFixture[str]) -> None:
133
+ logging.init_basic_logging()
134
+ logging.api(action='MESSAGE', path='a:b', query='x=1:2')
135
+ assert _lines(capsys) == ['MESSAGE:a__b:x=1__2::']
136
+
137
+ def test_nothing_is_logged_when_the_api_logger_is_disabled(self, capsys: pytest.CaptureFixture[str]) -> None:
138
+ logging.init_basic_logging()
139
+ logging.API_LOGGER.setLevel(pythonLogging.WARNING)
140
+ logging.api(action='GET', path='/', query='')
141
+ assert _lines(capsys) == []
142
+
143
+
144
+ class TestException:
145
+ def test_logs_kiba_exception_message_with_traceback(self, capsys: pytest.CaptureFixture[str]) -> None:
146
+ logging.init_basic_logging()
147
+ try:
148
+ raise NotFoundException(message='user not found')
149
+ except NotFoundException as exception:
150
+ logging.exception(exception)
151
+ lines = _lines(capsys)
152
+ assert lines[0] == 'user not found'
153
+ assert lines[1] == 'Traceback (most recent call last):'
154
+ assert lines[-1].endswith("message='user not found')")
155
+
156
+ def test_logs_plain_exception_text(self, capsys: pytest.CaptureFixture[str]) -> None:
157
+ logging.init_basic_logging()
158
+ try:
159
+ raise ValueError('bad value')
160
+ except ValueError as exception:
161
+ logging.exception(exception)
162
+ lines = _lines(capsys)
163
+ assert (lines[0], lines[-1]) == ('bad value', 'ValueError: bad value')
164
+
165
+ def test_logs_string_message_without_traceback_when_disabled(self, capsys: pytest.CaptureFixture[str]) -> None:
166
+ logging.init_basic_logging()
167
+ logging.exception('something failed', exc_info=False)
168
+ assert _lines(capsys) == ['something failed']
169
+
170
+
171
+ class TestTextLogging:
172
+ def test_includes_service_details_request_id_level_and_location(self, capsys: pytest.CaptureFixture[str]) -> None:
173
+ requestIdHolder = RequestIdHolder()
174
+ requestIdHolder.set_value(value='req-123')
175
+ logging.init_logging(name='app', version='v1', environment='test', requestIdHolder=requestIdHolder)
176
+ logging.warning('hello')
177
+ parts = _lines(capsys)[0].split(' - ')
178
+ datetime.datetime.strptime(parts[0], '%Y-%m-%dT%H:%M:%S.%f') # noqa: DTZ007
179
+ assert parts[1:8] == ['KIBA_1', 'app', 'v1', 'test', 'req-123', 'WARNING', 'root']
180
+ assert parts[8].startswith('tests.test_logging.py:test_includes_service_details_request_id_level_and_location:')
181
+ assert parts[9] == 'hello'
182
+
183
+ def test_stat_api_and_exception_report_the_caller_location(self, capsys: pytest.CaptureFixture[str]) -> None:
184
+ logging.init_logging(name='app', version='v1', environment='test')
185
+ logging.stat(name='events', key='created')
186
+ logging.api(action='GET', path='/', query='')
187
+ logging.exception('failed', exc_info=False)
188
+ locations = [line.split(' - ')[8].rsplit(':', 1)[0] for line in _lines(capsys)]
189
+ assert locations == ['tests.test_logging.py:test_stat_api_and_exception_report_the_caller_location'] * 3
190
+
191
+ def test_uses_each_loggers_own_format(self, capsys: pytest.CaptureFixture[str]) -> None:
192
+ logging.init_logging(name='app', version='v1', environment='test')
193
+ logging.stat(name='events', key='created')
194
+ logging.api(action='GET', path='/', query='', response=200)
195
+ statParts, apiParts = (line.split(' - ') for line in _lines(capsys))
196
+ assert (statParts[1], statParts[-1]) == ('KIBA_STAT_1', 'events:created:1')
197
+ assert (apiParts[1], apiParts[-1]) == ('KIBA_API_1', 'GET:/::200:')
198
+
199
+ def test_request_id_is_empty_without_a_holder(self, capsys: pytest.CaptureFixture[str]) -> None:
200
+ logging.init_logging(name='app', version='v1', environment='test')
201
+ logging.info('hello')
202
+ assert _lines(capsys)[0].split(' - ')[5] == ''
203
+
204
+ def test_show_debug_includes_debug(self, capsys: pytest.CaptureFixture[str]) -> None:
205
+ logging.init_logging(name='app', version='v1', environment='test')
206
+ logging.debug('hidden')
207
+ logging.init_logging(name='app', version='v1', environment='test', showDebug=True)
208
+ logging.debug('shown')
209
+ assert [line.split(' - ')[-1] for line in _lines(capsys)] == ['shown']
210
+
211
+
212
+ class TestJsonLogging:
213
+ def test_writes_one_json_object_per_log_with_service_details(self, capsys: pytest.CaptureFixture[str]) -> None:
214
+ requestIdHolder = RequestIdHolder()
215
+ requestIdHolder.set_value(value='req-123')
216
+ logging.init_json_logging(name='app', version='v1', environment='test', requestIdHolder=requestIdHolder)
217
+ logging.info('hello %s', 'kiba')
218
+ record = _json_lines(capsys)[0]
219
+ loggedDate = datetime.datetime.strptime(str(record.pop('date')), '%Y-%m-%dT%H:%M:%S.%f').replace(tzinfo=datetime.UTC)
220
+ assert abs((datetime.datetime.now(tz=datetime.UTC) - loggedDate).total_seconds()) < 60 # noqa: PLR2004
221
+ # NOTE(krishan711): the json formatter logs the unformatted msg, not msg % args
222
+ assert record == {'message': 'hello %s', 'level': 'INFO', 'logger': 'root', 'format': 'KIBA_1', 'name': 'app', 'version': 'v1', 'environment': 'test', 'requestId': 'req-123'}
223
+
224
+ def test_request_id_is_null_without_a_holder(self, capsys: pytest.CaptureFixture[str]) -> None:
225
+ logging.init_json_logging(name='app', version='v1', environment='test')
226
+ logging.info('hello')
227
+ assert _json_lines(capsys)[0]['requestId'] is None
228
+
229
+ def test_exceptions_log_the_traceback_as_the_message(self, capsys: pytest.CaptureFixture[str]) -> None:
230
+ logging.init_json_logging(name='app', version='v1', environment='test')
231
+ try:
232
+ raise ValueError('bad value')
233
+ except ValueError as exception:
234
+ logging.exception(exception)
235
+ record = _json_lines(capsys)[0]
236
+ assert (record['level'], str(record['message']).splitlines()[0], str(record['message']).splitlines()[-1]) == ('ERROR', 'Traceback (most recent call last):', 'ValueError: bad value')
237
+
238
+ def test_stat_fields_are_typed(self, capsys: pytest.CaptureFixture[str]) -> None:
239
+ logging.init_json_logging(name='app', version='v1', environment='test')
240
+ logging.stat(name='events', key='created', value=2.5)
241
+ record = _json_lines(capsys)[0]
242
+ assert {key: record[key] for key in ('format', 'message', 'statName', 'statKey', 'statValue')} == {'format': 'KIBA_STAT_1', 'message': None, 'statName': 'events', 'statKey': 'created', 'statValue': 2.5}
243
+
244
+ def test_api_fields_are_typed_and_missing_values_are_null(self, capsys: pytest.CaptureFixture[str]) -> None:
245
+ logging.init_json_logging(name='app', version='v1', environment='test')
246
+ logging.api(action='GET', path='/v1/users/123', pathPattern='/v1/users/{userId}', query='limit=10', response=200, duration=0.25)
247
+ logging.api(action='CRON', path='job', query='')
248
+ fields = ('format', 'apiAction', 'apiPath', 'apiPathPattern', 'apiQuery', 'apiResponse', 'apiDuration')
249
+ completeRecord, startRecord = ({key: record[key] for key in fields} for record in _json_lines(capsys))
250
+ assert completeRecord == {'format': 'KIBA_API_1', 'apiAction': 'GET', 'apiPath': '/v1/users/123', 'apiPathPattern': '/v1/users/{userId}', 'apiQuery': 'limit=10', 'apiResponse': 200, 'apiDuration': 0.25}
251
+ assert startRecord == {'format': 'KIBA_API_1', 'apiAction': 'CRON', 'apiPath': 'job', 'apiPathPattern': 'job', 'apiQuery': None, 'apiResponse': None, 'apiDuration': None}
252
+
253
+
254
+ class TestJsonFieldParsers:
255
+ def test_empty_values_parse_to_none(self) -> None:
256
+ assert (logging.json_parse_string_value(''), logging.json_parse_int_value(''), logging.json_parse_float_value('')) == (None, None, None)
257
+
258
+ def test_values_parse_to_their_types(self) -> None:
259
+ assert (logging.json_parse_string_value('abc'), logging.json_parse_int_value('42'), logging.json_parse_float_value('1.5')) == ('abc', 42, 1.5)