kstlib 2.6.0__tar.gz → 2.7.1__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 (196) hide show
  1. {kstlib-2.6.0/src/kstlib.egg-info → kstlib-2.7.1}/PKG-INFO +2 -2
  2. {kstlib-2.6.0 → kstlib-2.7.1}/pyproject.toml +1 -1
  3. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/__init__.py +6 -0
  4. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/callback.py +1 -1
  5. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/check.py +1 -0
  6. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/errors.py +9 -0
  7. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/oauth2.py +1 -1
  8. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/common.py +1 -0
  9. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/list_sessions.py +1 -1
  10. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/pool.py +2 -2
  11. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/kstlib.conf.yml +38 -0
  12. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/limits.py +14 -0
  13. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/logging/manager.py +4 -2
  14. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/__init__.py +10 -1
  15. kstlib-2.7.1/src/kstlib/mail/_helpers.py +81 -0
  16. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/builder.py +48 -38
  17. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/exceptions.py +14 -0
  18. kstlib-2.7.1/src/kstlib/mail/throttle.py +520 -0
  19. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/ses.py +1 -0
  20. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/meta.py +1 -1
  21. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/delivery.py +1 -1
  22. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/service.py +2 -2
  23. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/_base.py +1 -0
  24. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/spinner.py +1 -0
  25. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/manager.py +1 -1
  26. {kstlib-2.6.0 → kstlib-2.7.1/src/kstlib.egg-info}/PKG-INFO +2 -2
  27. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/SOURCES.txt +2 -0
  28. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/requires.txt +1 -1
  29. {kstlib-2.6.0 → kstlib-2.7.1}/LICENSE.md +0 -0
  30. {kstlib-2.6.0 → kstlib-2.7.1}/MANIFEST.in +0 -0
  31. {kstlib-2.6.0 → kstlib-2.7.1}/README.md +0 -0
  32. {kstlib-2.6.0 → kstlib-2.7.1}/setup.cfg +0 -0
  33. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/__main__.py +0 -0
  34. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/__init__.py +0 -0
  35. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/jinja.py +0 -0
  36. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/redaction.py +0 -0
  37. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/__init__.py +0 -0
  38. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/__init__.py +0 -0
  39. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/base.py +0 -0
  40. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/email.py +0 -0
  41. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/slack.py +0 -0
  42. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/exceptions.py +0 -0
  43. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/manager.py +0 -0
  44. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/models.py +0 -0
  45. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/throttle.py +0 -0
  46. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/__init__.py +0 -0
  47. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/config.py +0 -0
  48. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/models.py +0 -0
  49. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/__init__.py +0 -0
  50. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/base.py +0 -0
  51. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/oidc.py +0 -0
  52. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/session.py +0 -0
  53. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/token.py +0 -0
  54. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/__init__.py +0 -0
  55. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/decorator.py +0 -0
  56. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/strategies.py +0 -0
  57. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/__init__.py +0 -0
  58. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/app.py +0 -0
  59. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/__init__.py +0 -0
  60. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/__init__.py +0 -0
  61. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/check.py +0 -0
  62. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/common.py +0 -0
  63. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/login.py +0 -0
  64. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/logout.py +0 -0
  65. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/providers.py +0 -0
  66. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/status.py +0 -0
  67. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/token.py +0 -0
  68. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/whoami.py +0 -0
  69. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/config.py +0 -0
  70. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/__init__.py +0 -0
  71. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/attach.py +0 -0
  72. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/logs.py +0 -0
  73. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/start.py +0 -0
  74. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/status.py +0 -0
  75. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/stop.py +0 -0
  76. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/__init__.py +0 -0
  77. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/call.py +0 -0
  78. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/list.py +0 -0
  79. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/show.py +0 -0
  80. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/__init__.py +0 -0
  81. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/common.py +0 -0
  82. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/decrypt.py +0 -0
  83. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/doctor.py +0 -0
  84. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/encrypt.py +0 -0
  85. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/shred.py +0 -0
  86. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/common.py +0 -0
  87. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/__init__.py +0 -0
  88. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/exceptions.py +0 -0
  89. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/export.py +0 -0
  90. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/loader.py +0 -0
  91. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/sops.py +0 -0
  92. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/__init__.py +0 -0
  93. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/aiosqlcipher.py +0 -0
  94. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/cipher.py +0 -0
  95. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/database.py +0 -0
  96. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/exceptions.py +0 -0
  97. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/__init__.py +0 -0
  98. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/exceptions.py +0 -0
  99. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/time_trigger.py +0 -0
  100. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/logging/__init__.py +0 -0
  101. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/collector.py +0 -0
  102. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/filesystem.py +0 -0
  103. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transport.py +0 -0
  104. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/__init__.py +0 -0
  105. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/gmail.py +0 -0
  106. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/resend.py +0 -0
  107. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/smtp.py +0 -0
  108. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/__init__.py +0 -0
  109. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/decorators.py +0 -0
  110. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/exceptions.py +0 -0
  111. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/__init__.py +0 -0
  112. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/_styles.py +0 -0
  113. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/cell.py +0 -0
  114. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/config.py +0 -0
  115. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/exceptions.py +0 -0
  116. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/image.py +0 -0
  117. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/kv.py +0 -0
  118. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/list.py +0 -0
  119. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/metric.py +0 -0
  120. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/monitoring.py +0 -0
  121. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/renderer.py +0 -0
  122. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/table.py +0 -0
  123. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/types.py +0 -0
  124. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/__init__.py +0 -0
  125. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/base.py +0 -0
  126. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/container.py +0 -0
  127. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/exceptions.py +0 -0
  128. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/manager.py +0 -0
  129. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/models.py +0 -0
  130. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/tmux.py +0 -0
  131. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/validators.py +0 -0
  132. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/__init__.py +0 -0
  133. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/base.py +0 -0
  134. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/exceptions.py +0 -0
  135. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/models.py +0 -0
  136. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/runner.py +0 -0
  137. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/__init__.py +0 -0
  138. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/_helpers.py +0 -0
  139. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/callable.py +0 -0
  140. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/python.py +0 -0
  141. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/shell.py +0 -0
  142. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/validators.py +0 -0
  143. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/py.typed +0 -0
  144. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/__init__.py +0 -0
  145. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/client.py +0 -0
  146. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/config.py +0 -0
  147. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/credentials.py +0 -0
  148. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/exceptions.py +0 -0
  149. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/__init__.py +0 -0
  150. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/circuit_breaker.py +0 -0
  151. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/exceptions.py +0 -0
  152. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/heartbeat.py +0 -0
  153. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/rate_limiter.py +0 -0
  154. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/shutdown.py +0 -0
  155. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/watchdog.py +0 -0
  156. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/__init__.py +0 -0
  157. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/exceptions.py +0 -0
  158. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/models.py +0 -0
  159. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/__init__.py +0 -0
  160. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/base.py +0 -0
  161. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/environment.py +0 -0
  162. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/keyring.py +0 -0
  163. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/kms.py +0 -0
  164. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/kwargs.py +0 -0
  165. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/sops.py +0 -0
  166. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/resolver.py +0 -0
  167. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/sensitive.py +0 -0
  168. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/__init__.py +0 -0
  169. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/fs.py +0 -0
  170. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/permissions.py +0 -0
  171. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ssl.py +0 -0
  172. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/__init__.py +0 -0
  173. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/chain.py +0 -0
  174. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/config.py +0 -0
  175. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/exceptions.py +0 -0
  176. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/primitives.py +0 -0
  177. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/validators.py +0 -0
  178. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/__init__.py +0 -0
  179. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/exceptions.py +0 -0
  180. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/panels.py +0 -0
  181. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/tables.py +0 -0
  182. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/__init__.py +0 -0
  183. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/dict.py +0 -0
  184. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/formatting.py +0 -0
  185. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/http_trace.py +0 -0
  186. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/lazy.py +0 -0
  187. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/secure_delete.py +0 -0
  188. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/serialization.py +0 -0
  189. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/text.py +0 -0
  190. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/validators.py +0 -0
  191. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/__init__.py +0 -0
  192. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/exceptions.py +0 -0
  193. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/models.py +0 -0
  194. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/dependency_links.txt +0 -0
  195. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/entry_points.txt +0 -0
  196. {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kstlib
3
- Version: 2.6.0
3
+ Version: 2.7.1
4
4
  Summary: Config-driven helpers for Python projects (dynamic config, secure secrets, preset logging, and more…)
5
5
  Author-email: Michel TRUONG <michel.truong@gmail.com>
6
6
  Maintainer-email: Michel TRUONG <michel.truong@gmail.com>
@@ -46,7 +46,7 @@ Requires-Dist: authlib<2,>=1.6.11
46
46
  Requires-Dist: pendulum<4,>=3.0
47
47
  Requires-Dist: cryptography>=46.0.7
48
48
  Requires-Dist: requests>=2.33.0
49
- Requires-Dist: urllib3>=2.6.3
49
+ Requires-Dist: urllib3>=2.7.0
50
50
  Provides-Extra: dev
51
51
  Requires-Dist: pytest<10,>=9.0.3; extra == "dev"
52
52
  Requires-Dist: pytest-cov<8,>=7.0; extra == "dev"
@@ -64,7 +64,7 @@ dependencies = [
64
64
  # --- Security: transitive dep lower bounds (CVE) ---
65
65
  "cryptography>=46.0.7", # CVE-2026-26007 + CVE-2026-34073 + CVE-2026-39892
66
66
  "requests>=2.33.0", # CVE-2024-47081 + CVE-2026-25645 (via httpx)
67
- "urllib3>=2.6.3", # CVE-2025-50182/50181 + CVE-2025-66418/66471 + CVE-2026-21441 (via requests)
67
+ "urllib3>=2.7.0", # CVE-2025-50182/50181 + CVE-2025-66418/66471 + CVE-2026-21441 + CVE-2026-44431 + CVE-2026-44432 (via requests)
68
68
  ]
69
69
  classifiers = [
70
70
  "Development Status :: 5 - Production/Stable",
@@ -39,6 +39,11 @@ from __future__ import annotations
39
39
  import importlib
40
40
  from typing import TYPE_CHECKING, Any
41
41
 
42
+ # Eager-loaded version string (PEP 396 / standard ergonomy).
43
+ # Imported eagerly so `kstlib.__version__` works without triggering the
44
+ # lazy-loader. The cost is negligible (one trivial module load).
45
+ from kstlib.meta import __version__
46
+
42
47
  # Public API exports (sorted alphabetically)
43
48
  __all__ = [
44
49
  "ConfigCircularIncludeError",
@@ -52,6 +57,7 @@ __all__ = [
52
57
  "MonitoringError",
53
58
  "PanelManager",
54
59
  "PanelRenderingError",
60
+ "__version__",
55
61
  "alerts",
56
62
  "app",
57
63
  "auth",
@@ -327,7 +327,7 @@ class CallbackServer: # pylint: disable=too-many-instance-attributes
327
327
  if self._server is None:
328
328
  return
329
329
  while not self._stop_flag and self._server:
330
- try:
330
+ try: # reason: per-iteration tolerance for HTTP request handler loop
331
331
  self._server.handle_request()
332
332
  except Exception: # pylint: disable=broad-exception-caught
333
333
  if not self._stop_flag:
@@ -171,6 +171,7 @@ class TokenChecker:
171
171
  expected_issuer: str | None = None,
172
172
  expected_audience: str | None = None,
173
173
  ) -> None:
174
+ """Initialize the token checker with an HTTP client and optional expected claims."""
174
175
  self._http = http_client
175
176
  self._expected_issuer = expected_issuer
176
177
  self._expected_audience = expected_audience
@@ -11,6 +11,7 @@ class AuthError(KstlibError):
11
11
  """Base exception for all authentication errors."""
12
12
 
13
13
  def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
14
+ """Initialize the auth error with a message and optional structured details."""
14
15
  super().__init__(message)
15
16
  self.message = message
16
17
  self.details = details or {}
@@ -24,6 +25,7 @@ class ProviderNotFoundError(AuthError):
24
25
  """Raised when a named provider is not configured."""
25
26
 
26
27
  def __init__(self, provider_name: str) -> None:
28
+ """Initialize with the name of the missing provider."""
27
29
  super().__init__(f"Provider '{provider_name}' not found in configuration")
28
30
  self.provider_name = provider_name
29
31
 
@@ -32,6 +34,7 @@ class DiscoveryError(AuthError):
32
34
  """Raised when OIDC discovery fails."""
33
35
 
34
36
  def __init__(self, issuer: str, reason: str) -> None:
37
+ """Initialize with the failing issuer URL and the reason for the failure."""
35
38
  super().__init__(f"Discovery failed for '{issuer}': {reason}")
36
39
  self.issuer = issuer
37
40
  self.reason = reason
@@ -49,6 +52,7 @@ class TokenRefreshError(TokenError):
49
52
  """Raised when token refresh fails."""
50
53
 
51
54
  def __init__(self, reason: str, *, retryable: bool = False) -> None:
55
+ """Initialize with the reason for the refresh failure and a retryable flag."""
52
56
  super().__init__(f"Token refresh failed: {reason}")
53
57
  self.reason = reason
54
58
  self.retryable = retryable
@@ -58,6 +62,7 @@ class TokenExchangeError(TokenError):
58
62
  """Raised when authorization code exchange fails."""
59
63
 
60
64
  def __init__(self, reason: str, *, error_code: str | None = None) -> None:
65
+ """Initialize with the reason for the exchange failure and an optional OAuth error code."""
61
66
  super().__init__(f"Token exchange failed: {reason}")
62
67
  self.reason = reason
63
68
  self.error_code = error_code
@@ -67,6 +72,7 @@ class TokenValidationError(TokenError):
67
72
  """Raised when JWT validation fails (signature, claims, expiry)."""
68
73
 
69
74
  def __init__(self, reason: str, *, claim: str | None = None) -> None:
75
+ """Initialize with the reason for the validation failure and the offending claim name."""
70
76
  super().__init__(f"Token validation failed: {reason}")
71
77
  self.reason = reason
72
78
  self.claim = claim
@@ -86,6 +92,7 @@ class AuthorizationError(AuthError):
86
92
  error_code: str | None = None,
87
93
  error_description: str | None = None,
88
94
  ) -> None:
95
+ """Initialize with the reason for the failure plus optional OAuth error code and description."""
89
96
  super().__init__(f"Authorization failed: {reason}")
90
97
  self.reason = reason
91
98
  self.error_code = error_code
@@ -96,6 +103,7 @@ class CallbackServerError(AuthError):
96
103
  """Raised when the local callback server fails to start or receive callback."""
97
104
 
98
105
  def __init__(self, reason: str, *, port: int | None = None) -> None:
106
+ """Initialize with the reason for the callback server failure and the port that was in use."""
99
107
  super().__init__(f"Callback server error: {reason}")
100
108
  self.reason = reason
101
109
  self.port = port
@@ -105,6 +113,7 @@ class PreflightError(AuthError):
105
113
  """Raised when preflight validation fails."""
106
114
 
107
115
  def __init__(self, step: str, reason: str) -> None:
116
+ """Initialize with the failing preflight step name and the reason for the failure."""
108
117
  super().__init__(f"Preflight failed at '{step}': {reason}")
109
118
  self.step = step
110
119
  self.reason = reason
@@ -453,7 +453,7 @@ class OAuth2Provider(AbstractAuthProvider):
453
453
 
454
454
  success = False
455
455
  for token_type_hint, token_value in tokens_to_revoke:
456
- try:
456
+ try: # reason: per-token revocation; partial success allowed (RFC 7009)
457
457
  data: dict[str, Any] = {
458
458
  "token": token_value,
459
459
  "token_type_hint": token_type_hint,
@@ -202,6 +202,7 @@ def _scan_tmux_sockets(name: str) -> tuple[str | None, str | None]:
202
202
  Tuple of (backend, socket_name). Both are ``None`` if no match is
203
203
  found. When a socket holds the session, ``("tmux", <sock>)`` is
204
204
  returned.
205
+
205
206
  """
206
207
  for sock in discover_tmux_sockets():
207
208
  try:
@@ -163,7 +163,7 @@ def _collect_sessions(backend: str | None) -> list[SessionStatus]:
163
163
 
164
164
  # Discover sessions on custom tmux sockets
165
165
  for socket_name in discover_tmux_sockets():
166
- try:
166
+ try: # reason: per-socket discovery; missing tmux socket is non-fatal
167
167
  tmux = TmuxRunner(socket_name=socket_name)
168
168
  sessions.extend(tmux.list_sessions())
169
169
  except BackendNotFoundError:
@@ -281,7 +281,7 @@ class ConnectionPool:
281
281
  async with self._lock:
282
282
  # Close all connections
283
283
  for conn in self._connections:
284
- try:
284
+ try: # reason: per-connection best-effort close on pool teardown
285
285
  await conn.execute("PRAGMA optimize")
286
286
  await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
287
287
  await conn.close()
@@ -292,7 +292,7 @@ class ConnectionPool:
292
292
 
293
293
  # Empty the queue
294
294
  while not self._pool.empty():
295
- try:
295
+ try: # reason: race-safe queue drain (QueueEmpty IS the loop terminator)
296
296
  self._pool.get_nowait()
297
297
  except asyncio.QueueEmpty:
298
298
  break
@@ -424,6 +424,39 @@ mail:
424
424
  # Leave null to force callers to pass transport= or preset= explicitly.
425
425
  default: null
426
426
 
427
+ # Anti-spam throttle (kill switch). Enforced before the transport on
428
+ # every MailBuilder.send(), including indirect calls via @mail.notify.
429
+ #
430
+ # Cascade (highest priority first):
431
+ # 1. mail.presets.<name>.throttle.<key> (preset-level override)
432
+ # 2. mail.throttle.<key> (this section, mail-wide default)
433
+ # 3. Code defaults (rate=20, per=60.0, on_exceed=raise)
434
+ #
435
+ # Each key cascades independently: a preset can override only ``rate``
436
+ # while keeping the mail-wide ``per`` and ``on_exceed``.
437
+ #
438
+ # Modes:
439
+ # - raise (default): emits WARNING [SECURITY] then raises
440
+ # MailThrottledError. The caller decides how to back off.
441
+ # - warn: emits WARNING [SECURITY] then drops the mail silently
442
+ # (returns the built message without sending).
443
+ # - drop (silent) is INTENTIONALLY REJECTED at init: a security
444
+ # event must never be silent (kstlib logging convention).
445
+ #
446
+ # Singleton: a single MailThrottle is shared across all builders that
447
+ # use the same preset, including snapshots taken by the @notify
448
+ # decorator. This prevents bypass via creating many builder instances.
449
+ #
450
+ # Hard limits enforced in code (see kstlib.limits):
451
+ # - rate: 1 to 1000 mails per period
452
+ # - per: 1.0 to 86400.0 seconds (1 day)
453
+ # - on_exceed: "raise" or "warn"
454
+ throttle:
455
+ enabled: true
456
+ rate: 20
457
+ per: 60.0
458
+ on_exceed: raise
459
+
427
460
  # SSL/TLS configuration for mail transports.
428
461
  #
429
462
  # Values here override the root ``ssl:`` section (bottom of this file)
@@ -461,6 +494,11 @@ mail:
461
494
  # defaults:
462
495
  # sender: "Service Notifications <notify@corp.local>"
463
496
  # reply_to: "Service Notifications <notify@corp.local>"
497
+ # # Optional throttle override (highest priority, see mail.throttle above):
498
+ # throttle:
499
+ # rate: 5 # corporate is more restrictive than mail-wide default
500
+ # per: 60.0
501
+ # on_exceed: warn # operational critical, prefer drop+log over raise
464
502
  #
465
503
  # transactional:
466
504
  # transport: resend
@@ -16,6 +16,8 @@ Example:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ from typing import Literal as _Literal
20
+
19
21
  # =============================================================================
20
22
  # Pre-import constants (hard limits + defaults)
21
23
  #
@@ -126,6 +128,11 @@ HARD_MAX_THROTTLE_RATE = 1000
126
128
  HARD_MIN_THROTTLE_PER = 1.0
127
129
  HARD_MAX_THROTTLE_PER = 86400.0 # 1 day
128
130
 
131
+ #: Mail throttle registry size cap. Beyond this number of distinct preset
132
+ #: names, get_or_create_throttle refuses new entries to bound memory under
133
+ #: multi-tenant misuse (e.g. dynamic UUID-suffixed preset names).
134
+ HARD_MAX_THROTTLE_REGISTRY_SIZE = 100
135
+
129
136
  #: Alert channel timeout bounds (seconds) - protects against too short or hanging requests.
130
137
  HARD_MIN_CHANNEL_TIMEOUT = 1.0
131
138
  HARD_MAX_CHANNEL_TIMEOUT = 120.0
@@ -246,6 +253,13 @@ DEFAULT_THROTTLE_BURST = 5 # initial capacity
246
253
  DEFAULT_CHANNEL_TIMEOUT = 30.0 # seconds
247
254
  DEFAULT_CHANNEL_RETRIES = 2
248
255
 
256
+ #: Mail throttle defaults (anti-spam kill switch).
257
+ #: Hard limits are shared with alerts (HARD_MIN/MAX_THROTTLE_RATE / _PER).
258
+ #: Mode 'drop' is intentionally not supported (security event must never be silent).
259
+ DEFAULT_MAIL_THROTTLE_RATE = 20 # mails per period
260
+ DEFAULT_MAIL_THROTTLE_PER = 60.0 # seconds
261
+ DEFAULT_MAIL_THROTTLE_ON_EXCEED: _Literal["raise", "warn"] = "raise"
262
+
249
263
  DEFAULT_WS_PING_INTERVAL = 20.0 # seconds
250
264
  DEFAULT_WS_PING_TIMEOUT = 10.0 # seconds
251
265
  DEFAULT_WS_CONNECTION_TIMEOUT = 30.0 # seconds
@@ -74,7 +74,8 @@ FORBIDDEN_PATH_COMPONENTS: frozenset[str] = frozenset({"..", "~"})
74
74
  ALLOWED_LOG_EXTENSIONS: frozenset[str] = frozenset({".log", ".txt", ".json", ""})
75
75
 
76
76
 
77
- # TODO: Add aiofiles for true async file I/O
77
+ # Native async file I/O via aiofiles is a potential future enhancement;
78
+ # current implementation uses thread-pool wrappers (see methods below).
78
79
  # try:
79
80
  # import aiofiles
80
81
  # import aiofiles.os
@@ -882,7 +883,8 @@ class LogManager(logging.Logger):
882
883
  """Return whether native async logs are available."""
883
884
  return HAS_ASYNC
884
885
 
885
- # Async logging methods (TODO: implement with aiofiles)
886
+ # Async logging methods (thread-pool wrappers; native aiofiles via
887
+ # optional dep is a potential future optimization).
886
888
 
887
889
  async def atrace(self, msg: str, **context: Any) -> None:
888
890
  """Async trace wrapper executed via thread pool."""
@@ -26,8 +26,15 @@ Examples:
26
26
 
27
27
  from kstlib.mail.builder import MailBuilder, NotifyResult
28
28
  from kstlib.mail.collector import NotifyCollector
29
- from kstlib.mail.exceptions import MailConfigurationError, MailError, MailTransportError, MailValidationError
29
+ from kstlib.mail.exceptions import (
30
+ MailConfigurationError,
31
+ MailError,
32
+ MailThrottledError,
33
+ MailTransportError,
34
+ MailValidationError,
35
+ )
30
36
  from kstlib.mail.filesystem import MailFilesystemGuards
37
+ from kstlib.mail.throttle import MailThrottle
31
38
  from kstlib.mail.transport import AsyncMailTransport, AsyncTransportWrapper, MailTransport
32
39
 
33
40
  __all__ = [
@@ -37,6 +44,8 @@ __all__ = [
37
44
  "MailConfigurationError",
38
45
  "MailError",
39
46
  "MailFilesystemGuards",
47
+ "MailThrottle",
48
+ "MailThrottledError",
40
49
  "MailTransport",
41
50
  "MailTransportError",
42
51
  "MailValidationError",
@@ -0,0 +1,81 @@
1
+ """Internal helpers for the mail subpackage.
2
+
3
+ Single source of truth for the ``mail`` configuration section access
4
+ shared by :mod:`kstlib.mail.builder` (cascade transport / SSL / preset
5
+ resolution) and :mod:`kstlib.mail.throttle` (anti-spam kill switch
6
+ init).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from kstlib.mail.exceptions import MailConfigurationError
14
+
15
+
16
+ def _load_mail_section(*, silent: bool = False) -> Any:
17
+ """Read the ``mail`` section from the kstlib configuration.
18
+
19
+ Two distinct contracts share this implementation:
20
+
21
+ - ``silent=False`` (used by :mod:`kstlib.mail.builder`):
22
+ preserves the legacy single-exception-type contract. Every loader
23
+ failure (``ImportError``, ``ConfigNotLoadedError``, ``YAMLError``,
24
+ ``OSError``, ``RuntimeError``, ...) is wrapped in
25
+ :class:`~kstlib.mail.MailConfigurationError`. Existing builder
26
+ callers and tests rely on this wrap.
27
+
28
+ - ``silent=True`` (used by :mod:`kstlib.mail.throttle`):
29
+ narrow catch. Only "config absent / not loaded" errors
30
+ (``ImportError``, ``ConfigNotLoadedError``,
31
+ ``MailConfigurationError``) are suppressed and return ``None``.
32
+ Real corruption (``YAMLError``, ``OSError``, ``RuntimeError``,
33
+ ...) propagates so the throttle init crashes explicitly rather
34
+ than silently disabling.
35
+
36
+ The asymmetry is intentional: the throttle is an operational
37
+ kill switch, surfacing corruption is preferable to silently
38
+ weakening the safety net. Full unification (narrow catch on both
39
+ paths) would be a breaking change for the builder API and is
40
+ deferred to a future major release.
41
+
42
+ Args:
43
+ silent: If ``True``, suppress "config not loaded" errors and
44
+ return ``None``. Real corruption errors always propagate.
45
+
46
+ Returns:
47
+ The mail section as a Box / dict, or ``None`` if the section
48
+ is missing or (``silent=True``) the config is not loaded.
49
+
50
+ Raises:
51
+ MailConfigurationError: If ``silent=False`` and the config
52
+ cannot be loaded for any reason. The original exception is
53
+ chained via ``__cause__``.
54
+
55
+ """
56
+ try:
57
+ from kstlib.config import get_config
58
+ from kstlib.config.exceptions import ConfigNotLoadedError
59
+ except ImportError as exc: # pragma: no cover - config is always present
60
+ if silent:
61
+ return None
62
+ raise MailConfigurationError("kstlib.config is not available") from exc
63
+
64
+ try:
65
+ cfg: Any = get_config()
66
+ except (ConfigNotLoadedError, MailConfigurationError) as exc:
67
+ if silent:
68
+ return None
69
+ raise MailConfigurationError(f"Failed to load kstlib configuration: not loaded ({exc})") from exc
70
+ except Exception as exc:
71
+ # Real loader corruption (YAMLError, OSError, RuntimeError, ...).
72
+ # silent path: propagate to surface the bug at builder/throttle init.
73
+ # non-silent path: wrap into MailConfigurationError to preserve the
74
+ # legacy single-exception API that existing builder callers rely on.
75
+ if silent:
76
+ raise
77
+ raise MailConfigurationError(f"Failed to load kstlib configuration: {exc}") from exc
78
+
79
+ if not hasattr(cfg, "get"):
80
+ return None
81
+ return cfg.get("mail")
@@ -20,8 +20,10 @@ from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, overload
20
20
  from kstlib._shared.jinja import render_jinja
21
21
  from kstlib.limits import MailLimits, get_mail_limits
22
22
  from kstlib.logging import get_logger
23
+ from kstlib.mail._helpers import _load_mail_section
23
24
  from kstlib.mail.exceptions import MailConfigurationError, MailTransportError, MailValidationError
24
25
  from kstlib.mail.filesystem import MailFilesystemGuards
26
+ from kstlib.mail.throttle import MailThrottle, get_or_create_throttle
25
27
  from kstlib.ssl import get_ssl_config, validate_ca_bundle_path
26
28
  from kstlib.utils import (
27
29
  EmailAddress,
@@ -181,6 +183,7 @@ class MailBuilder:
181
183
  encoding: str = _DEFAULT_ENCODING,
182
184
  filesystem: MailFilesystemGuards | None = None,
183
185
  limits: MailLimits | None = None,
186
+ throttle: bool | dict[str, Any] | None = None,
184
187
  ) -> None:
185
188
  """Initialise the builder with optional transport, preset, charset, and guardrails.
186
189
 
@@ -198,11 +201,20 @@ class MailBuilder:
198
201
  filesystem: Filesystem guardrails for attachments, inline
199
202
  resources, and templates.
200
203
  limits: Message and attachment limits.
204
+ throttle: Anti-spam kill switch. ``False`` disables the
205
+ throttle on this builder. A ``dict`` (e.g.
206
+ ``{"rate": 100, "per": 3600.0, "on_exceed": "warn"}``)
207
+ builds a per-instance custom throttle. ``None`` (default)
208
+ resolves the throttle from configuration via the cascade
209
+ ``mail.presets.<name>.throttle`` > ``mail.throttle`` >
210
+ code defaults. See :class:`~kstlib.mail.MailThrottle` for
211
+ the available knobs.
201
212
 
202
213
  Raises:
203
214
  MailConfigurationError: If ``preset`` is passed but does not
204
- resolve to a valid preset in configuration, or if a preset
205
- default has a non-string value.
215
+ resolve to a valid preset in configuration, if a preset
216
+ default has a non-string value, or if the resolved
217
+ throttle configuration is invalid.
206
218
  MailValidationError: If a preset default holds an unparseable
207
219
  email address.
208
220
 
@@ -220,6 +232,7 @@ class MailBuilder:
220
232
  self._encoding = encoding
221
233
  self._filesystem = filesystem or MailFilesystemGuards.default()
222
234
  self._limits = limits or get_mail_limits()
235
+ self._throttle: MailThrottle | None = get_or_create_throttle(resolved_preset_name, throttle)
223
236
  self._sender: EmailAddress | None = None
224
237
  self._reply_to: EmailAddress | None = None
225
238
  self._to: list[EmailAddress] = []
@@ -380,12 +393,23 @@ class MailBuilder:
380
393
  def send(self) -> EmailMessage:
381
394
  """Build and send the email using the configured transport.
382
395
 
396
+ If a :class:`~kstlib.mail.MailThrottle` is attached (default,
397
+ unless disabled via ``throttle=False``), it is consulted before
398
+ the transport. When the bucket is empty, the throttle either
399
+ raises :class:`~kstlib.mail.MailThrottledError` (mode ``raise``)
400
+ or logs a security warning and returns the built message without
401
+ sending (mode ``warn``).
402
+
383
403
  Returns:
384
- The constructed EmailMessage after successful delivery.
404
+ The constructed :class:`~email.message.EmailMessage`. In
405
+ ``warn`` mode, the returned message may have been dropped
406
+ silently (a ``WARNING [SECURITY]`` log is emitted).
385
407
 
386
408
  Raises:
387
409
  MailConfigurationError: If no transport has been configured.
388
410
  MailTransportError: If the transport fails to deliver the message.
411
+ MailThrottledError: If the throttle is in ``raise`` mode and
412
+ the bucket is empty.
389
413
 
390
414
  """
391
415
  from kstlib.mail.transport import AsyncMailTransport, MailTransport
@@ -400,6 +424,8 @@ class MailBuilder:
400
424
  )
401
425
  assert isinstance(self._transport, MailTransport)
402
426
  message = self.build()
427
+ if self._throttle is not None and not self._throttle.consume(self._subject):
428
+ return message
403
429
  try:
404
430
  self._transport.send(message)
405
431
  except MailTransportError:
@@ -415,17 +441,27 @@ class MailBuilder:
415
441
  def _snapshot(self) -> MailBuilder:
416
442
  """Create an independent copy of this builder for decoration.
417
443
 
418
- Returns a copy that shares the transport but has independent
419
- message state, so decorated functions don't interfere with each other.
444
+ Returns a copy that shares the transport and throttle but has
445
+ independent message state, so decorated functions don't interfere
446
+ with each other. The throttle MUST be shared (not deep-copied)
447
+ so that ``@mail.notify`` on a hot function cannot bypass the
448
+ rate limit by getting its own bucket.
420
449
  """
421
- # Save transport before deepcopy (transports should not be copied)
450
+ # Save transport and throttle before deepcopy: neither can be
451
+ # safely deep-copied (transport may hold sockets, throttle holds
452
+ # a threading.Lock) and both must be shared by reference so the
453
+ # snapshot enforces the same kill switch as the original builder.
422
454
  transport = self._transport
455
+ throttle = self._throttle
423
456
  self._transport = None
457
+ self._throttle = None
424
458
  try:
425
459
  snapshot = copy.deepcopy(self)
426
460
  finally:
427
461
  self._transport = transport
462
+ self._throttle = throttle
428
463
  snapshot._transport = transport
464
+ snapshot._throttle = throttle
429
465
  return snapshot
430
466
 
431
467
  @overload
@@ -935,32 +971,6 @@ def _detect_mime(path: Path) -> tuple[str, str]:
935
971
  _SUPPORTED_TRANSPORTS = ("smtp", "resend")
936
972
 
937
973
 
938
- def _load_mail_config() -> Any:
939
- """Read ``mail`` section from kstlib configuration.
940
-
941
- Returns:
942
- The ``mail`` section as a Box/dict, or ``None`` if unavailable.
943
-
944
- Raises:
945
- MailConfigurationError: If the config loader cannot be imported or
946
- raises while reading.
947
-
948
- """
949
- try:
950
- from kstlib.config import get_config
951
- except ImportError as exc: # pragma: no cover - config is always present
952
- raise MailConfigurationError("kstlib.config is not available") from exc
953
-
954
- try:
955
- cfg: Any = get_config()
956
- except Exception as exc:
957
- raise MailConfigurationError(f"Failed to load kstlib configuration: {exc}") from exc
958
-
959
- if not hasattr(cfg, "get"):
960
- return None
961
- return cfg.get("mail")
962
-
963
-
964
974
  def _resolve_default_transport() -> TransportLike | None:
965
975
  """Resolve the transport from ``mail.default`` in configuration.
966
976
 
@@ -974,7 +984,7 @@ def _resolve_default_transport() -> TransportLike | None:
974
984
 
975
985
  """
976
986
  try:
977
- mail_cfg = _load_mail_config()
987
+ mail_cfg = _load_mail_section()
978
988
  except MailConfigurationError:
979
989
  return None
980
990
 
@@ -992,10 +1002,10 @@ def _resolve_default_preset_name() -> str | None:
992
1002
 
993
1003
  Swallows any config loading error and returns ``None`` so that
994
1004
  ``MailBuilder()`` stays usable even when the config file is missing.
995
- Mirrors the silent-empty contract of :func:`_load_mail_config`.
1005
+ Mirrors the silent-empty contract of :func:`_load_mail_section`.
996
1006
  """
997
1007
  try:
998
- mail_cfg = _load_mail_config()
1008
+ mail_cfg = _load_mail_section()
999
1009
  except MailConfigurationError:
1000
1010
  return None
1001
1011
  if mail_cfg is None or not hasattr(mail_cfg, "get"):
@@ -1029,7 +1039,7 @@ def _load_preset_envelope_defaults(preset_name: str) -> dict[str, Any]:
1029
1039
 
1030
1040
  """
1031
1041
  try:
1032
- mail_cfg = _load_mail_config()
1042
+ mail_cfg = _load_mail_section()
1033
1043
  except MailConfigurationError:
1034
1044
  return {}
1035
1045
  if mail_cfg is None or not hasattr(mail_cfg, "get"):
@@ -1078,7 +1088,7 @@ def _build_transport_from_preset(preset_name: str) -> TransportLike:
1078
1088
  field is missing or unsupported, or required fields are absent.
1079
1089
 
1080
1090
  """
1081
- mail_cfg = _load_mail_config()
1091
+ mail_cfg = _load_mail_section()
1082
1092
  if mail_cfg is None:
1083
1093
  raise MailConfigurationError(
1084
1094
  f"Preset '{preset_name}' cannot be resolved: 'mail' section missing from configuration"
@@ -1113,7 +1123,7 @@ def _load_mail_ssl_section() -> Any | None:
1113
1123
  config loader cannot be reached.
1114
1124
  """
1115
1125
  try:
1116
- mail_section = _load_mail_config()
1126
+ mail_section = _load_mail_section()
1117
1127
  except MailConfigurationError:
1118
1128
  return None
1119
1129
  if mail_section is None or not hasattr(mail_section, "get"):
@@ -21,9 +21,23 @@ class MailConfigurationError(MailError):
21
21
  """Raised when the mail builder is missing required configuration."""
22
22
 
23
23
 
24
+ class MailThrottledError(MailError):
25
+ """Raised when the mail throttle bucket is empty and on_exceed is 'raise'.
26
+
27
+ Carries the throttle parameters in the message to help the caller
28
+ decide on a backoff strategy. The throttle is config-driven via
29
+ ``mail.throttle.*`` and acts as an anti-spam kill switch.
30
+
31
+ See Also:
32
+ :class:`kstlib.mail.MailThrottle`
33
+
34
+ """
35
+
36
+
24
37
  __all__ = [
25
38
  "MailConfigurationError",
26
39
  "MailError",
40
+ "MailThrottledError",
27
41
  "MailTransportError",
28
42
  "MailValidationError",
29
43
  ]