python-corekit 0.2.0__tar.gz → 0.3.0__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 (206) hide show
  1. {python_corekit-0.2.0 → python_corekit-0.3.0}/PKG-INFO +7 -7
  2. {python_corekit-0.2.0 → python_corekit-0.3.0}/README.md +6 -6
  3. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/application.py +47 -9
  4. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/lifespan.py +26 -3
  5. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/concurrency/__init__.py +2 -2
  6. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/concurrency/decorators.py +32 -5
  7. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/concurrency/thread_local.py +2 -2
  8. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/concurrency/worker.py +9 -0
  9. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/config/loader.py +42 -5
  10. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/config/settings.py +11 -1
  11. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/__init__.py +7 -1
  12. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/connectable.py +45 -4
  13. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/redis/connection.py +53 -10
  14. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/__init__.py +2 -1
  15. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/connection.py +39 -5
  16. python_corekit-0.3.0/corekit/connections/sql/fields/__init__.py +7 -0
  17. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/fields/jsonb.py +13 -6
  18. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/migration/__init__.py +4 -0
  19. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/migration/operations.py +69 -2
  20. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/operations/base.py +11 -2
  21. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/operations/statements.py +25 -5
  22. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/table.py +7 -29
  23. python_corekit-0.3.0/corekit/crypto/__init__.py +3 -0
  24. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/crypto/constants.py +2 -2
  25. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/crypto/hasher.py +9 -4
  26. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/dataset.py +8 -2
  27. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/expressions/__init__.py +3 -3
  28. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/expressions/comparison.py +19 -80
  29. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/expressions/expression.py +0 -32
  30. python_corekit-0.3.0/corekit/data/expressions/operator.py +39 -0
  31. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/stats.py +3 -0
  32. python_corekit-0.3.0/corekit/decorators/exception_handling.py +72 -0
  33. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/docker/watchdog.py +50 -31
  34. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/__init__.py +2 -1
  35. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/connection.py +14 -12
  36. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/extract/extractor.py +6 -13
  37. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/orchestrator.py +19 -2
  38. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/schemas.py +2 -2
  39. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/transform/transformer.py +4 -1
  40. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/publisher.py +1 -1
  41. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/reader.py +26 -21
  42. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/sse.py +4 -1
  43. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/websocket.py +24 -11
  44. python_corekit-0.3.0/corekit/exceptions/__init__.py +33 -0
  45. python_corekit-0.3.0/corekit/exceptions/base.py +174 -0
  46. python_corekit-0.3.0/corekit/exceptions/enum.py +17 -0
  47. python_corekit-0.3.0/corekit/exceptions/types.py +17 -0
  48. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/files/__init__.py +2 -4
  49. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/files/base.py +15 -2
  50. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/files/enum.py +0 -5
  51. python_corekit-0.3.0/corekit/files/json.py +26 -0
  52. python_corekit-0.3.0/corekit/http/__init__.py +51 -0
  53. python_corekit-0.3.0/corekit/http/api.py +24 -0
  54. python_corekit-0.3.0/corekit/http/client.py +203 -0
  55. python_corekit-0.3.0/corekit/http/exceptions.py +140 -0
  56. python_corekit-0.3.0/corekit/http/response.py +61 -0
  57. python_corekit-0.3.0/corekit/http/status.py +89 -0
  58. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/jobs/runner.py +12 -1
  59. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/jobs/task.py +23 -2
  60. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/log_monitor/models.py +8 -2
  61. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/log_monitor/service.py +77 -38
  62. python_corekit-0.3.0/corekit/notifications/base.py +59 -0
  63. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/__init__.py +9 -2
  64. python_corekit-0.3.0/corekit/observability/benchmarkable.py +30 -0
  65. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/loggable.py +21 -0
  66. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/request_context.py +55 -2
  67. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/timing/timer.py +4 -2
  68. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/registry/__init__.py +2 -2
  69. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/registry/registry.py +55 -14
  70. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/enum.py +22 -1
  71. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/types.py +6 -1
  72. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/serialization/__init__.py +2 -0
  73. python_corekit-0.3.0/corekit/serialization/pickle_file.py +61 -0
  74. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/serialization/serializable.py +22 -2
  75. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/serialization/serializer.py +9 -2
  76. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/collections.py +22 -13
  77. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/payload.py +12 -0
  78. {python_corekit-0.2.0 → python_corekit-0.3.0}/pyproject.toml +2 -2
  79. {python_corekit-0.2.0 → python_corekit-0.3.0}/python_corekit.egg-info/PKG-INFO +7 -7
  80. {python_corekit-0.2.0 → python_corekit-0.3.0}/python_corekit.egg-info/SOURCES.txt +9 -3
  81. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_application.py +46 -0
  82. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_architecture.py +5 -1
  83. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_config.py +6 -5
  84. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_connections.py +26 -0
  85. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_data.py +10 -0
  86. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_docker.py +13 -2
  87. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_etl.py +24 -1
  88. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_events.py +67 -6
  89. python_corekit-0.3.0/tests/test_exceptions.py +90 -0
  90. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_expressions.py +10 -88
  91. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_expressions_sqlalchemy.py +26 -3
  92. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_files.py +23 -3
  93. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_jobs.py +32 -0
  94. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_lifespan.py +14 -0
  95. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_log_monitor.py +115 -0
  96. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_migration.py +42 -0
  97. python_corekit-0.3.0/tests/test_notifications.py +40 -0
  98. python_corekit-0.3.0/tests/test_observability.py +51 -0
  99. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_operations.py +36 -0
  100. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_redis.py +47 -4
  101. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_registry.py +34 -1
  102. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_request_context.py +33 -1
  103. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_requests.py +58 -5
  104. python_corekit-0.3.0/tests/test_schemas_enum.py +36 -0
  105. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_serialization.py +25 -0
  106. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_sql.py +45 -1
  107. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils.py +24 -1
  108. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_collections.py +44 -0
  109. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_payload.py +16 -0
  110. python_corekit-0.2.0/corekit/connections/sql/fields/__init__.py +0 -7
  111. python_corekit-0.2.0/corekit/constants.py +0 -45
  112. python_corekit-0.2.0/corekit/crypto/__init__.py +0 -1
  113. python_corekit-0.2.0/corekit/data/expressions/operator.py +0 -54
  114. python_corekit-0.2.0/corekit/decorators/exception_handling.py +0 -44
  115. python_corekit-0.2.0/corekit/exceptions/__init__.py +0 -18
  116. python_corekit-0.2.0/corekit/exceptions/base.py +0 -45
  117. python_corekit-0.2.0/corekit/exceptions/http/exceptions.py +0 -37
  118. python_corekit-0.2.0/corekit/exceptions/types.py +0 -17
  119. python_corekit-0.2.0/corekit/files/json.py +0 -12
  120. python_corekit-0.2.0/corekit/files/pickle.py +0 -12
  121. python_corekit-0.2.0/corekit/http/__init__.py +0 -13
  122. python_corekit-0.2.0/corekit/http/client.py +0 -176
  123. python_corekit-0.2.0/corekit/http/response.py +0 -12
  124. python_corekit-0.2.0/corekit/notifications/base.py +0 -51
  125. python_corekit-0.2.0/corekit/observability/benchmarkable.py +0 -12
  126. {python_corekit-0.2.0 → python_corekit-0.3.0}/LICENSE +0 -0
  127. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/__init__.py +0 -0
  128. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/__init__.py +0 -0
  129. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/handler.py +0 -0
  130. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/middleware.py +0 -0
  131. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/responses.py +0 -0
  132. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/api/routers.py +0 -0
  133. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/config/__init__.py +0 -0
  134. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/config/sources.py +0 -0
  135. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/decorators.py +0 -0
  136. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/redis/__init__.py +0 -0
  137. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/registry.py +0 -0
  138. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/migration/base.py +0 -0
  139. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/migration/registry.py +0 -0
  140. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/migration/table.py +0 -0
  141. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/operations/__init__.py +0 -0
  142. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/connections/sql/query.py +0 -0
  143. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/crypto/enum.py +0 -0
  144. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/__init__.py +0 -0
  145. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/expressions/target.py +0 -0
  146. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/data/record.py +0 -0
  147. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/decorators/__init__.py +0 -0
  148. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/decorators/warnings.py +0 -0
  149. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/docker/__init__.py +0 -0
  150. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/extract/__init__.py +0 -0
  151. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/extract/schemas.py +0 -0
  152. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/load/__init__.py +0 -0
  153. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/load/loader.py +0 -0
  154. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/load/schemas.py +0 -0
  155. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/transform/__init__.py +0 -0
  156. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/etl/transform/schemas.py +0 -0
  157. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/__init__.py +0 -0
  158. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/enum.py +0 -0
  159. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/frames.py +0 -0
  160. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/events/models.py +0 -0
  161. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/exceptions/custom/__init__.py +0 -0
  162. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/exceptions/http/__init__.py +0 -0
  163. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/files/toml.py +0 -0
  164. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/http/exponential_backoff.py +0 -0
  165. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/jobs/__init__.py +0 -0
  166. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/jobs/registry.py +0 -0
  167. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/log_monitor/__init__.py +0 -0
  168. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/log_monitor/constants.py +0 -0
  169. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/notifications/__init__.py +0 -0
  170. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/notifications/models.py +0 -0
  171. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/timing/__init__.py +0 -0
  172. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/timing/constants.py +0 -0
  173. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/observability/timing/split.py +0 -0
  174. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/py.typed +0 -0
  175. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/registry/ordered.py +0 -0
  176. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/__init__.py +0 -0
  177. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/dataclasses/__init__.py +0 -0
  178. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/models/__init__.py +0 -0
  179. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/models/arbitrary.py +0 -0
  180. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/models/date_models.py +0 -0
  181. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/pydantic/__init__.py +0 -0
  182. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/schemas/pydantic/fields.py +0 -0
  183. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/serialization/enum.py +0 -0
  184. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/__init__.py +0 -0
  185. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/coercion.py +0 -0
  186. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/ids.py +0 -0
  187. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/raise_exc.py +0 -0
  188. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/text.py +0 -0
  189. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/time.py +0 -0
  190. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/validators.py +0 -0
  191. {python_corekit-0.2.0 → python_corekit-0.3.0}/corekit/utils/void.py +0 -0
  192. {python_corekit-0.2.0 → python_corekit-0.3.0}/python_corekit.egg-info/dependency_links.txt +0 -0
  193. {python_corekit-0.2.0 → python_corekit-0.3.0}/python_corekit.egg-info/requires.txt +0 -0
  194. {python_corekit-0.2.0 → python_corekit-0.3.0}/python_corekit.egg-info/top_level.txt +0 -0
  195. {python_corekit-0.2.0 → python_corekit-0.3.0}/setup.cfg +0 -0
  196. {python_corekit-0.2.0 → python_corekit-0.3.0}/setup.py +0 -0
  197. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_api.py +0 -0
  198. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_concurrency.py +0 -0
  199. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_imports.py +0 -0
  200. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_imports_are_top_level.py +0 -0
  201. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_middleware_stack.py +0 -0
  202. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_ordered_registry.py +0 -0
  203. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_coercion.py +0 -0
  204. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_ids.py +0 -0
  205. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_text.py +0 -0
  206. {python_corekit-0.2.0 → python_corekit-0.3.0}/tests/test_utils_time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-corekit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Shared foundations for Python projects: logging, benchmarking, registries, FastAPI application and routers, SQL statements and migrations, background tasks, and ETL
5
5
  Author: Steven Jacobsen
6
6
  License-Expression: MIT
@@ -55,7 +55,7 @@ extras to remember, and no import that fails because something was left out.
55
55
  Pin a compatible release rather than tracking whatever is newest:
56
56
 
57
57
  ```
58
- python-corekit~=0.2.0
58
+ python-corekit~=0.3.0
59
59
  ```
60
60
 
61
61
  Before 1.0, the minor version carries breaking changes.
@@ -284,7 +284,7 @@ instead, which loses results silently and so is opt-in.
284
284
  ## HTTP clients
285
285
 
286
286
  ```python
287
- from corekit.http.client import BaseApiClient
287
+ from corekit.http import BaseApiClient
288
288
 
289
289
  class GithubClient(BaseApiClient):
290
290
  """
@@ -301,7 +301,8 @@ response.data["login"]
301
301
 
302
302
  Retries 429 and 5xx with exponential backoff. Every response is a
303
303
  `BaseApiResponse`, so a non-JSON error page leaves `data` empty rather than
304
- raising. `async_get`, `async_post` and friends do the same without blocking.
304
+ raising. The same methods are awaitable inside a running event loop:
305
+ `await client.get(...)`.
305
306
 
306
307
  ## Serialization
307
308
 
@@ -351,7 +352,7 @@ Settings are grouped by concern, so `get_settings().concurrency.max_threads`
351
352
  says where a value belongs. Environment variables use a double underscore for
352
353
  the section: `COREKIT_CONCURRENCY__MAX_THREADS=16`.
353
354
 
354
- Environment variables use a `COREKIT_` prefix (`COREKIT_CRYPTO_SALT`). Empty
355
+ Environment variables use a `COREKIT_` prefix (`COREKIT_CRYPTO__SALT`). Empty
355
356
  values are treated as unset, because container runtimes routinely pass `FOO=`
356
357
  for a variable that was never set.
357
358
 
@@ -375,7 +376,6 @@ Imports go downward only.
375
376
  ```
376
377
  corekit/
377
378
  config/ settings, sources, loader
378
- constants.py
379
379
 
380
380
  exceptions/ error types
381
381
 
@@ -390,7 +390,7 @@ corekit/
390
390
  connections/ the Connectable lifecycle and @connect
391
391
  sql/ SQLConnection, statements, migrations
392
392
  redis/ RedisConnection
393
- http/ BaseApiClient, retries, responses
393
+ http/ BaseHttpClient, BaseApiClient, retries, responses
394
394
 
395
395
  api/ Application, lifespan, middleware, routers
396
396
  docker/ notifications/ etl/
@@ -20,7 +20,7 @@ extras to remember, and no import that fails because something was left out.
20
20
  Pin a compatible release rather than tracking whatever is newest:
21
21
 
22
22
  ```
23
- python-corekit~=0.2.0
23
+ python-corekit~=0.3.0
24
24
  ```
25
25
 
26
26
  Before 1.0, the minor version carries breaking changes.
@@ -249,7 +249,7 @@ instead, which loses results silently and so is opt-in.
249
249
  ## HTTP clients
250
250
 
251
251
  ```python
252
- from corekit.http.client import BaseApiClient
252
+ from corekit.http import BaseApiClient
253
253
 
254
254
  class GithubClient(BaseApiClient):
255
255
  """
@@ -266,7 +266,8 @@ response.data["login"]
266
266
 
267
267
  Retries 429 and 5xx with exponential backoff. Every response is a
268
268
  `BaseApiResponse`, so a non-JSON error page leaves `data` empty rather than
269
- raising. `async_get`, `async_post` and friends do the same without blocking.
269
+ raising. The same methods are awaitable inside a running event loop:
270
+ `await client.get(...)`.
270
271
 
271
272
  ## Serialization
272
273
 
@@ -316,7 +317,7 @@ Settings are grouped by concern, so `get_settings().concurrency.max_threads`
316
317
  says where a value belongs. Environment variables use a double underscore for
317
318
  the section: `COREKIT_CONCURRENCY__MAX_THREADS=16`.
318
319
 
319
- Environment variables use a `COREKIT_` prefix (`COREKIT_CRYPTO_SALT`). Empty
320
+ Environment variables use a `COREKIT_` prefix (`COREKIT_CRYPTO__SALT`). Empty
320
321
  values are treated as unset, because container runtimes routinely pass `FOO=`
321
322
  for a variable that was never set.
322
323
 
@@ -340,7 +341,6 @@ Imports go downward only.
340
341
  ```
341
342
  corekit/
342
343
  config/ settings, sources, loader
343
- constants.py
344
344
 
345
345
  exceptions/ error types
346
346
 
@@ -355,7 +355,7 @@ corekit/
355
355
  connections/ the Connectable lifecycle and @connect
356
356
  sql/ SQLConnection, statements, migrations
357
357
  redis/ RedisConnection
358
- http/ BaseApiClient, retries, responses
358
+ http/ BaseHttpClient, BaseApiClient, retries, responses
359
359
 
360
360
  api/ Application, lifespan, middleware, routers
361
361
  docker/ notifications/ etl/
@@ -26,16 +26,34 @@ from typing import Any, Sequence
26
26
 
27
27
  from fastapi import FastAPI
28
28
  from fastapi.middleware.cors import CORSMiddleware
29
+ from fastapi.responses import JSONResponse
29
30
  from fastapi.staticfiles import StaticFiles
30
31
  from starlette.middleware.trustedhost import TrustedHostMiddleware
31
32
 
32
33
  from corekit.api.middleware import MiddlewareStack
33
34
  from corekit.api.routers import CatchAllRouter, SimpleRouter, router_registry
35
+ from corekit.exceptions import CoreHTTPException
34
36
  from corekit.observability.loggable import Loggable
35
37
 
36
38
  __all__ = ["Application"]
37
39
 
38
40
 
41
+ def _origin_in_package(router: SimpleRouter, package_name: str) -> bool:
42
+ """
43
+ Whether this router was constructed by ``package_name`` or a submodule.
44
+ """
45
+ origin = router._origin_module
46
+ return origin == package_name or origin.startswith(f"{package_name}.")
47
+
48
+
49
+ async def _core_http_exception_handler(request: Any, exc: CoreHTTPException) -> JSONResponse:
50
+ """
51
+ The same body FastAPI returns for ``HTTPException``.
52
+ """
53
+ headers = getattr(exc, "headers", None)
54
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=headers)
55
+
56
+
39
57
  class Application(FastAPI, Loggable):
40
58
  """
41
59
  ``FastAPI`` plus router discovery, middleware helpers and static mounting.
@@ -47,6 +65,7 @@ class Application(FastAPI, Loggable):
47
65
  """
48
66
  FastAPI.__init__(self, *args, **kwargs)
49
67
  Loggable.__init__(self)
68
+ self._register_http_exception_handler()
50
69
 
51
70
  # ─── Routers ──────────────────────────────────────────────────────────
52
71
 
@@ -55,9 +74,12 @@ class Application(FastAPI, Loggable):
55
74
  Import every module under ``package`` and mount the routers it defines.
56
75
 
57
76
  Importing a module is what constructs its routers, so discovery is a
58
- walk over ``package`` that lets each one register itself. A router that
59
- was included into another router during that walk is skipped -- its
60
- parent already owns it -- and any ``CatchAllRouter`` is mounted last.
77
+ walk over ``package`` that lets each one register itself. Only routers
78
+ constructed inside that package are mounted: the registry is
79
+ process-wide, and an unclaimed router from some other import must not
80
+ ride along. A router that was included into another router is skipped
81
+ -- its parent already owns it -- and any ``CatchAllRouter`` is mounted
82
+ last.
61
83
 
62
84
  Args:
63
85
  package: The imported package to search, e.g. ``app.backend.routers``.
@@ -78,11 +100,12 @@ class Application(FastAPI, Loggable):
78
100
 
79
101
  self._warn_on_unimportable_dirs(package)
80
102
 
81
- before = len(router_registry)
82
103
  scanned = self._import_submodules(package)
83
- discovered = len(router_registry) - before
84
-
85
- mountable = router_registry.unclaimed
104
+ # unclaimed is every top-level router in the process. Mounting that
105
+ # list would include routers built by some other package that happens
106
+ # to be imported. Origin is where the instance was constructed, which
107
+ # is the scanned package for a router that belongs here.
108
+ mountable = [router for router in router_registry.unclaimed if _origin_in_package(router, name)]
86
109
  if not mountable:
87
110
  raise ValueError(
88
111
  f"No routers found in '{name}' (scanned {scanned} modules). "
@@ -90,8 +113,9 @@ class Application(FastAPI, Loggable):
90
113
  f"a SimpleRouter or SmartRouter at import time."
91
114
  )
92
115
 
93
- claimed = discovered - len(mountable)
94
- self.info(f" scanned {scanned} modules, found {discovered} routers")
116
+ in_package = [router for router in router_registry if _origin_in_package(router, name)]
117
+ claimed = len(in_package) - len(mountable)
118
+ self.info(f" scanned {scanned} modules, found {len(in_package)} routers")
95
119
  if claimed > 0:
96
120
  plural = "s" if claimed != 1 else ""
97
121
  self.info(f" skipped {claimed} sub-router{plural} (already included by a parent)")
@@ -235,3 +259,17 @@ class Application(FastAPI, Loggable):
235
259
  self.mount(url_path, StaticFiles(directory=str(path), **kwargs), name=name or url_path.strip("/"))
236
260
  self.info(f" + static {url_path:<24} -> {path}")
237
261
  return True
262
+
263
+ def _register_http_exception_handler(self) -> None:
264
+ """
265
+ Handle the library's HTTP exceptions the same way FastAPI handles its own.
266
+
267
+ ``CoreHTTPException`` is already an ``HTTPException``, so Starlette
268
+ would answer it. Registering the type explicitly keeps that response
269
+ -- ``{"detail": ...}`` only, never ``message`` or ``error`` -- even
270
+ if a later change in lookup order would otherwise miss the subclass.
271
+ A handler the caller already installed is left in place.
272
+ """
273
+ if CoreHTTPException in self.exception_handlers:
274
+ return
275
+ self.add_exception_handler(CoreHTTPException, _core_http_exception_handler)
@@ -108,7 +108,9 @@ class Lifespan(Loggable):
108
108
  startup: Called on the way up, in the order steps were added.
109
109
  shutdown: Called on the way down, in reverse order. Called only if
110
110
  ``startup`` completed, so it can assume what ``startup`` builds.
111
- tags: Free-form labels, for callers that group or filter steps.
111
+ tags: Labels used by ``for_tags``. Untagged steps always run; a
112
+ tagged step runs only when one of its tags is selected. With
113
+ no filter, every step runs.
112
114
  always_shutdown: Call ``shutdown`` even when ``startup`` raised.
113
115
  For a startup that acquires something before it can fail -- a
114
116
  connection opened, then a handshake that throws. The hook then
@@ -136,6 +138,25 @@ class Lifespan(Loggable):
136
138
  """The registered steps, in the order they will start."""
137
139
  return list(self._steps)
138
140
 
141
+ def for_tags(self, *tags: str) -> "Lifespan":
142
+ """
143
+ A lifespan that runs the steps matching ``tags``, plus untagged ones.
144
+
145
+ An untagged step is ordinary startup and always runs. A tagged step
146
+ runs only when the caller asked for one of its tags, so a label is a
147
+ filter rather than decoration. No tags means every step, which is
148
+ what ``__call__`` does.
149
+ """
150
+ if not tags:
151
+ return self
152
+
153
+ wanted = set(tags)
154
+ selected = Lifespan()
155
+ for step in self._steps:
156
+ if not step.tags or wanted.intersection(step.tags):
157
+ selected._steps.append(step)
158
+ return selected
159
+
139
160
  @asynccontextmanager
140
161
  async def __call__(self, app: Any = None) -> AsyncIterator[None]:
141
162
  """
@@ -166,7 +187,8 @@ class Lifespan(Loggable):
166
187
  stack.push_async_callback(self._run_shutdown, step)
167
188
 
168
189
  if step.startup is not None:
169
- self.info(f" ^ {step.name}")
190
+ label = step.name if not step.tags else f"{step.name} [{', '.join(step.tags)}]"
191
+ self.info(f" ^ {label}")
170
192
  try:
171
193
  await self._maybe_await(step.startup)
172
194
  except Exception:
@@ -192,7 +214,8 @@ class Lifespan(Loggable):
192
214
  self.warning(f" - {step.name} (skipped: startup did not complete)")
193
215
  return
194
216
 
195
- self.info(f" v {step.name}")
217
+ label = step.name if not step.tags else f"{step.name} [{', '.join(step.tags)}]"
218
+ self.info(f" v {label}")
196
219
  try:
197
220
  await self._maybe_await(step.shutdown)
198
221
  except Exception:
@@ -2,8 +2,8 @@
2
2
  Concurrency primitives: per-thread storage, workers, and parallel mapping.
3
3
  """
4
4
 
5
- from corekit.concurrency.decorators import parallelize
5
+ from corekit.concurrency.decorators import allow_sync, parallelize
6
6
  from corekit.concurrency.thread_local import ThreadLocalRegistry
7
7
  from corekit.concurrency.worker import ThreadWorker
8
8
 
9
- __all__ = ["ThreadLocalRegistry", "ThreadWorker", "parallelize"]
9
+ __all__ = ["ThreadLocalRegistry", "ThreadWorker", "allow_sync", "parallelize"]
@@ -18,19 +18,26 @@ machine rather than an error message.
18
18
 
19
19
  Results arrive out of order, because they arrive as they complete. Pass
20
20
  ``ordered=True`` to get them in the order the inputs were given.
21
+
22
+ ``allow_sync`` wraps an async function so a script can call it directly, while
23
+ an already-running event loop still gets a coroutine to await.
21
24
  """
22
25
 
26
+ import asyncio
23
27
  import functools
28
+ import logging
29
+ from collections.abc import Awaitable, Callable
24
30
  from concurrent.futures import Future, ThreadPoolExecutor, as_completed
25
- from typing import Any, Callable, Iterable, Iterator
31
+ from typing import Any, Iterable, Iterator, ParamSpec, TypeVar
26
32
 
27
33
  from corekit.config import get_settings
28
- from corekit.observability import Loggable
29
34
 
30
- __all__ = ["parallelize"]
35
+ __all__ = ["parallelize", "allow_sync"]
36
+
37
+ _logger = logging.getLogger("parallelize")
31
38
 
32
- _logger = Loggable()
33
- _logger.logger = _logger.logger.getChild("parallelize")
39
+ P = ParamSpec("P")
40
+ R = TypeVar("R")
34
41
 
35
42
 
36
43
  def parallelize(
@@ -70,3 +77,23 @@ def parallelize(
70
77
  return wrapper
71
78
 
72
79
  return decorator
80
+
81
+
82
+ def allow_sync(async_func: Callable[P, Awaitable[R]]) -> Callable[P, R | Awaitable[R]]:
83
+ """
84
+ Call an async function from sync code, or return its coroutine if a loop is running.
85
+
86
+ A script gets the result back immediately. Inside an async app the wrapper
87
+ returns the coroutine, so the caller must await it — calling the wrapper
88
+ and ignoring the result will not run the function.
89
+ """
90
+
91
+ @functools.wraps(async_func)
92
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Awaitable[R]:
93
+ try:
94
+ asyncio.get_running_loop()
95
+ except RuntimeError:
96
+ return asyncio.run(async_func(*args, **kwargs))
97
+ return async_func(*args, **kwargs)
98
+
99
+ return wrapper
@@ -19,7 +19,7 @@ import threading
19
19
  from typing import Any
20
20
 
21
21
  from corekit.observability.loggable import Loggable
22
- from corekit.registry import SmartRegistry
22
+ from corekit.registry import normalize_key
23
23
 
24
24
  __all__ = ["ThreadLocalRegistry"]
25
25
 
@@ -59,7 +59,7 @@ class ThreadLocalRegistry(threading.local, Loggable):
59
59
  turn SQLConnection into "sqlconnection" rather than "sql_connection".
60
60
  """
61
61
  name = key.__name__ if isinstance(key, type) else str(key)
62
- return SmartRegistry.__normalize_key__(name).replace("-", "_")
62
+ return normalize_key(name).replace("-", "_")
63
63
 
64
64
  def get(self, key: Any, fallback: Any = None) -> Any:
65
65
  """
@@ -5,6 +5,15 @@ import time
5
5
 
6
6
 
7
7
  class ThreadWorker(threading.Thread):
8
+ """
9
+ A daemon thread that waits for ``can_start``, then calls ``process`` once.
10
+
11
+ ``timeout`` and ``loop`` are stored for a subclass that wants them.
12
+ ``run`` does not enforce ``timeout`` and does not drive ``loop``.
13
+ ``stop`` sets ``running`` so a subclass of ``process`` can notice it; it
14
+ does not interrupt a thread already inside ``process``.
15
+ """
16
+
8
17
  def __init__(
9
18
  self,
10
19
  can_start: threading.Event,
@@ -9,6 +9,8 @@ settings know only their own shape.
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
+ from pydantic import ValidationError
13
+
12
14
  from corekit.config.settings import ENV_PREFIX, CorekitSettings
13
15
  from corekit.config.sources import ConfigFileSource, ConfigSource, EnvironmentSource, PyprojectSource
14
16
  from corekit.observability import Loggable
@@ -72,11 +74,12 @@ class SettingsLoader(Loggable):
72
74
  """
73
75
  Read every source and construct the settings.
74
76
 
75
- Invalid configuration falls back to defaults rather than raising:
76
- corekit has to remain importable, and a library that refuses to load
77
- because of a stray value in a file is worse than one that logs the
78
- problem and carries on. The log line is the difference between this and
79
- silently ignoring it.
77
+ Invalid configuration drops the bad section and keeps the rest.
78
+ A bad thread count must not discard a valid salt or serialization
79
+ key that arrived from another section or the environment. If what
80
+ remains still will not validate, the last resort is defaults --
81
+ corekit has to remain importable. The log line is the difference
82
+ between this and silently ignoring it.
80
83
  """
81
84
  values: dict[str, Any] = {}
82
85
  for source in self.sources():
@@ -84,10 +87,44 @@ class SettingsLoader(Loggable):
84
87
 
85
88
  try:
86
89
  return CorekitSettings(**values)
90
+ except ValidationError as exc:
91
+ cleaned, dropped = self._without_invalid_sections(values, exc)
92
+ if dropped:
93
+ self.error(
94
+ f"Invalid corekit configuration in {', '.join(dropped)}; "
95
+ f"those sections fall back to defaults: {exc}"
96
+ )
97
+ try:
98
+ return CorekitSettings(**cleaned)
99
+ except ValidationError as retry_exc:
100
+ self.error(f"Invalid corekit configuration, falling back to defaults: {retry_exc}")
101
+ return CorekitSettings()
102
+ self.error(f"Invalid corekit configuration, falling back to defaults: {exc}")
103
+ return CorekitSettings()
87
104
  except Exception as exc:
88
105
  self.error(f"Invalid corekit configuration, falling back to defaults: {exc}")
89
106
  return CorekitSettings()
90
107
 
108
+ @staticmethod
109
+ def _without_invalid_sections(values: dict[str, Any], exc: ValidationError) -> tuple[dict[str, Any], list[str]]:
110
+ """
111
+ Drop each section named in a validation error, leaving the others.
112
+
113
+ A section is the first element of the error location. A failure that
114
+ names no section cannot be isolated, so nothing is dropped and the
115
+ caller falls back to defaults.
116
+ """
117
+ dropped: list[str] = []
118
+ for error in exc.errors():
119
+ loc = error.get("loc") or ()
120
+ if not loc:
121
+ continue
122
+ section = str(loc[0])
123
+ if section not in dropped:
124
+ dropped.append(section)
125
+ cleaned = {key: value for key, value in values.items() if key not in dropped}
126
+ return cleaned, dropped
127
+
91
128
  @staticmethod
92
129
  def _merge(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
93
130
  """
@@ -35,6 +35,10 @@ class StandardsSettings(BaseModel):
35
35
 
36
36
  Off by default. These are conventions, not library invariants, and crashing
37
37
  a consumer's application over a missing docstring would be hostile.
38
+
39
+ ``strict_mode`` makes a ``SmartRegistry`` refuse a second write to a
40
+ normalized key instead of warning and replacing. It does not imply
41
+ ``require_handler_docstrings``; that check stays its own flag.
38
42
  """
39
43
 
40
44
  require_handler_docstrings: bool = False
@@ -80,7 +84,13 @@ class ConcurrencySettings(BaseModel):
80
84
  @staticmethod
81
85
  def cpu_default() -> int:
82
86
  """
83
- A reasonable thread count for this machine, for callers that want one.
87
+ A machine-sized thread count, for a caller that asks for one.
88
+
89
+ Not what ``resolve`` or ``parallelize`` use when the caller is silent.
90
+ Those use ``default_threads``. Pass this in when the work should
91
+ scale with the machine::
92
+
93
+ parallelize(num_threads=ConcurrencySettings.cpu_default())
84
94
  """
85
95
  return min(32, (os.cpu_count() or 1) * 5)
86
96
 
@@ -17,7 +17,12 @@ implementations of the abstraction directly above them, and there is no useful
17
17
  way to think about one without the other.
18
18
  """
19
19
 
20
- from corekit.connections.connectable import Connectable, ConnectableType, ConnectionPreference
20
+ from corekit.connections.connectable import (
21
+ Connectable,
22
+ ConnectableType,
23
+ ConnectionPreference,
24
+ ReadOnlyConnectionError,
25
+ )
21
26
  from corekit.connections.decorators import connect
22
27
  from corekit.connections.registry import ConnectionRegistry, registry
23
28
 
@@ -26,6 +31,7 @@ __all__ = [
26
31
  "ConnectableType",
27
32
  "ConnectionPreference",
28
33
  "ConnectionRegistry",
34
+ "ReadOnlyConnectionError",
29
35
  "connect",
30
36
  "registry",
31
37
  ]
@@ -21,22 +21,40 @@ class without importing it directly.
21
21
  from abc import ABC, abstractmethod
22
22
  from typing import Any
23
23
 
24
+ from corekit.exceptions import InternalCoreException, Retryability
24
25
  from corekit.observability.loggable import Loggable
25
26
  from corekit.registry import SmartRegistry
26
27
  from corekit.schemas.enum import StringEnum
27
28
 
28
- __all__ = ["Connectable", "ConnectableType", "ConnectionPreference"]
29
+ __all__ = ["Connectable", "ConnectableType", "ConnectionPreference", "ReadOnlyConnectionError"]
29
30
 
30
31
 
31
32
  class ConnectionPreference(StringEnum):
32
33
  """
33
- Whether a connection should prefer its sync or async path when both exist.
34
+ A stored hint for which path a caller prefers.
35
+
36
+ The base class records this and does not consult it. ``connect`` and
37
+ ``async_connect`` follow the method the caller invoked, and ``@connect``
38
+ always opens with ``with``. A subclass may read the hint; nothing here
39
+ switches paths because of it.
34
40
  """
35
41
 
36
42
  ASYNC = "async"
37
43
  SYNC = "sync"
38
44
 
39
45
 
46
+ class ReadOnlyConnectionError(InternalCoreException):
47
+ """
48
+ Raised when a write is attempted on a connection opened read-only.
49
+ """
50
+
51
+ def __init__(self, connection: str) -> None:
52
+ super().__init__(
53
+ message=f"{connection} is read-only",
54
+ retryable=Retryability.NON_RETRYABLE,
55
+ )
56
+
57
+
40
58
  class Connectable(Loggable, ABC):
41
59
  """
42
60
  Base class for anything with a connection lifecycle.
@@ -97,10 +115,20 @@ class Connectable(Loggable, ABC):
97
115
  @property
98
116
  def connection_preference(self) -> ConnectionPreference:
99
117
  """
100
- Whether this connection prefers its sync or async path.
118
+ The path hint recorded at construction. Not used to open the connection.
101
119
  """
102
120
  return self._connection_preference
103
121
 
122
+ def require_writable(self) -> None:
123
+ """
124
+ Raise if this connection was opened for reading only.
125
+
126
+ Corekit's own write methods call this. The base class does not wrap
127
+ every method, so a subclass with its own writes should call it too.
128
+ """
129
+ if self._read_only:
130
+ raise ReadOnlyConnectionError(type(self).__name__)
131
+
104
132
  def __repr__(self) -> str:
105
133
  return f"{self.__class__.__name__}(connected={self.is_connected})"
106
134
 
@@ -190,11 +218,24 @@ class Connectable(Loggable, ABC):
190
218
  self.debug(f"Terminating {self.__class__.__name__} connection")
191
219
  self._disconnect()
192
220
 
221
+ def _async_is_open(self) -> bool:
222
+ """
223
+ Whether the async path is already up.
224
+
225
+ ``is_connected`` is the sync flag. Redis keeps a process-wide sync pool
226
+ and a separate async client, so a sync pool must not stand in for the
227
+ async client. Classes without ``is_async_connected`` use ``is_connected``.
228
+ """
229
+ for cls in type(self).mro():
230
+ if "is_async_connected" in cls.__dict__:
231
+ return bool(self.is_async_connected)
232
+ return self.is_connected
233
+
193
234
  async def async_connect(self, force_reconnect: bool = False) -> None:
194
235
  """
195
236
  Connect asynchronously if not already connected.
196
237
  """
197
- if self.is_connected and not force_reconnect:
238
+ if self._async_is_open() and not force_reconnect:
198
239
  return
199
240
 
200
241
  self.debug(f"Establishing async {self.__class__.__name__} connection")