velocity-python 0.1.98__tar.gz → 0.1.99__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 (264) hide show
  1. {velocity_python-0.1.98/src/velocity_python.egg-info → velocity_python-0.1.99}/PKG-INFO +1 -1
  2. {velocity_python-0.1.98 → velocity_python-0.1.99}/pyproject.toml +1 -1
  3. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/__init__.py +1 -1
  4. velocity_python-0.1.99/src/velocity/aws/email.py +173 -0
  5. velocity_python-0.1.99/src/velocity/aws/tests/test_email.py +344 -0
  6. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/mail.py +147 -29
  7. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_mail_send.py +17 -0
  8. {velocity_python-0.1.98 → velocity_python-0.1.99/src/velocity_python.egg-info}/PKG-INFO +1 -1
  9. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  10. {velocity_python-0.1.98 → velocity_python-0.1.99}/LICENSE +0 -0
  11. {velocity_python-0.1.98 → velocity_python-0.1.99}/README.md +0 -0
  12. {velocity_python-0.1.98 → velocity_python-0.1.99}/setup.cfg +0 -0
  13. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/__init__.py +0 -0
  14. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/amplify.py +0 -0
  15. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/amplify_build.py +0 -0
  16. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/__init__.py +0 -0
  17. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/backfill.py +0 -0
  18. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/indexing.py +0 -0
  19. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/references.py +0 -0
  20. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/service.py +0 -0
  21. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/assets/usage_index.py +0 -0
  22. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/__init__.py +0 -0
  23. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/config.py +0 -0
  24. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/operations.py +0 -0
  25. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/policies.py +0 -0
  26. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/reconcile.py +0 -0
  27. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/deploy/targets.py +0 -0
  28. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/dirty_pipeline.py +0 -0
  29. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/__init__.py +0 -0
  30. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/base_handler.py +0 -0
  31. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/context.py +0 -0
  32. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/context_factory.py +0 -0
  33. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/exceptions.py +0 -0
  34. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  35. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/masquerade.py +0 -0
  36. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  37. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  38. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/job_handler.py +0 -0
  39. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  40. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/perf.py +0 -0
  41. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/response.py +0 -0
  42. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  43. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/job_results.py +0 -0
  44. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/s3.py +0 -0
  45. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/sms.py +0 -0
  46. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/ssm_config.py +0 -0
  47. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/__init__.py +0 -0
  48. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
  49. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
  50. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  51. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
  52. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
  53. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  54. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_response.py +0 -0
  55. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/aws/tests/test_sms.py +0 -0
  56. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/__init__.py +0 -0
  57. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/__init__.py +0 -0
  58. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/async_support.py +0 -0
  59. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/column.py +0 -0
  60. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/database.py +0 -0
  61. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/decorators.py +0 -0
  62. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/engine.py +0 -0
  63. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/jsonproxy.py +0 -0
  64. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/result.py +0 -0
  65. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/row.py +0 -0
  66. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/sequence.py +0 -0
  67. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/table.py +0 -0
  68. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/transaction.py +0 -0
  69. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/core/view.py +0 -0
  70. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/exceptions.py +0 -0
  71. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/maintenance.py +0 -0
  72. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/migrations.py +0 -0
  73. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/__init__.py +0 -0
  74. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/base/__init__.py +0 -0
  75. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/base/initializer.py +0 -0
  76. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/base/operators.py +0 -0
  77. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/base/sql.py +0 -0
  78. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/base/types.py +0 -0
  79. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/__init__.py +0 -0
  80. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/operators.py +0 -0
  81. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/reserved.py +0 -0
  82. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/sql.py +0 -0
  83. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/types.py +0 -0
  84. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/__init__.py +0 -0
  85. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/operators.py +0 -0
  86. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/reserved.py +0 -0
  87. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/sql.py +0 -0
  88. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/types.py +0 -0
  89. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  90. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/operators.py +0 -0
  91. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  92. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/sql.py +0 -0
  93. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/types.py +0 -0
  94. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  95. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  96. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  97. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  98. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/types.py +0 -0
  99. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/servers/tablehelper.py +0 -0
  100. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/__init__.py +0 -0
  101. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/common_db_test.py +0 -0
  102. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/__init__.py +0 -0
  103. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/common.py +0 -0
  104. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/conftest.py +0 -0
  105. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_column.py +0 -0
  106. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  107. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_database.py +0 -0
  108. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  109. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  110. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  111. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_result.py +0 -0
  112. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_row.py +0 -0
  113. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  114. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  115. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  116. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  117. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  118. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_table.py +0 -0
  119. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  120. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  121. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/sql/__init__.py +0 -0
  122. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/sql/common.py +0 -0
  123. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  124. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  125. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  126. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_db_utils.py +0 -0
  127. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_postgres.py +0 -0
  128. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  129. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  130. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_result_caching.py +0 -0
  131. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  132. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  133. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  134. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  135. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_sql_builder.py +0 -0
  136. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_tablehelper.py +0 -0
  137. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/tests/test_view_helper.py +0 -0
  138. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/db/utils.py +0 -0
  139. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/logging.py +0 -0
  140. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/__init__.py +0 -0
  141. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/conv/__init__.py +0 -0
  142. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/conv/iconv.py +0 -0
  143. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/conv/oconv.py +0 -0
  144. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/db.py +0 -0
  145. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/export.py +0 -0
  146. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/format.py +0 -0
  147. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/merge.py +0 -0
  148. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/pdf.py +0 -0
  149. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/__init__.py +0 -0
  150. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_db.py +0 -0
  151. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_fix.py +0 -0
  152. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_format.py +0 -0
  153. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_iconv.py +0 -0
  154. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_merge.py +0 -0
  155. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_oconv.py +0 -0
  156. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_original_error.py +0 -0
  157. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tests/test_timer.py +0 -0
  158. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/timer.py +0 -0
  159. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/misc/tools.py +0 -0
  160. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/__init__.py +0 -0
  161. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/core/__init__.py +0 -0
  162. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/core/capabilities.py +0 -0
  163. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/core/store.py +0 -0
  164. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/core/where.py +0 -0
  165. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/exceptions.py +0 -0
  166. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/__init__.py +0 -0
  167. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/base/__init__.py +0 -0
  168. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
  169. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
  170. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
  171. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
  172. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
  173. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
  174. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
  175. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
  176. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/memory/__init__.py +0 -0
  177. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
  178. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
  179. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/nosql/servers/redis/__init__.py +0 -0
  180. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/__init__.py +0 -0
  181. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/authorizenet_adapter.py +0 -0
  182. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/authorizenet_mirror.py +0 -0
  183. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/base_adapter.py +0 -0
  184. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/braintree_adapter.py +0 -0
  185. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/braintree_mirror.py +0 -0
  186. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/charge_rules.py +0 -0
  187. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/stripe_adapter.py +0 -0
  188. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity/payment/stripe_mirror.py +0 -0
  189. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  190. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity_python.egg-info/entry_points.txt +0 -0
  191. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity_python.egg-info/requires.txt +0 -0
  192. {velocity_python-0.1.98 → velocity_python-0.1.99}/src/velocity_python.egg-info/top_level.txt +0 -0
  193. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_activity_tracking_aborted_tx.py +0 -0
  194. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_amplify_build.py +0 -0
  195. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_asset_indexing.py +0 -0
  196. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_asset_references.py +0 -0
  197. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_assets_service.py +0 -0
  198. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_async_support.py +0 -0
  199. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_batch_operations.py +0 -0
  200. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_column_tx_arg.py +0 -0
  201. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_concurrency_safety.py +0 -0
  202. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_connection_pool.py +0 -0
  203. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_connection_resilience.py +0 -0
  204. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_context_job_descriptions.py +0 -0
  205. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_db_credentials_ssm_cascade.py +0 -0
  206. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_db_maintenance.py +0 -0
  207. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_decorators.py +0 -0
  208. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_delete_hook_fk_blocked.py +0 -0
  209. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_dirty_pipeline_fast_path.py +0 -0
  210. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_email_processing.py +0 -0
  211. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_enqueue_send_failures.py +0 -0
  212. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_get_cognito_user_provider.py +0 -0
  213. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_http_handler_rollback.py +0 -0
  214. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_iconv_money_to_cents.py +0 -0
  215. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_identifier_injection_guard.py +0 -0
  216. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_job_handler.py +0 -0
  217. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_job_results.py +0 -0
  218. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_json_columns.py +0 -0
  219. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_jsonb_dict_adapter.py +0 -0
  220. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_lambda_handler.py +0 -0
  221. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_lambda_handler_auth.py +0 -0
  222. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_lambda_handler_masquerade.py +0 -0
  223. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_masquerade_grant.py +0 -0
  224. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_mixins_import.py +0 -0
  225. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_n_plus_one.py +0 -0
  226. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_dynamodb.py +0 -0
  227. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_memory.py +0 -0
  228. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_mongodb.py +0 -0
  229. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_redis.py +0 -0
  230. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_translation.py +0 -0
  231. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_nosql_where.py +0 -0
  232. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_observability.py +0 -0
  233. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_on_commit.py +0 -0
  234. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_authorizenet_adapter.py +0 -0
  235. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_braintree_adapter.py +0 -0
  236. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_braintree_mirror.py +0 -0
  237. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_mirror_empty_table.py +0 -0
  238. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_profile_sorting.py +0 -0
  239. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_payment_stripe_adapter.py +0 -0
  240. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_pdf.py +0 -0
  241. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_prepared_statements.py +0 -0
  242. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_psycopg3_upgrade.py +0 -0
  243. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_query_cache.py +0 -0
  244. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_restricted_direct_tables.py +0 -0
  245. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_retry_side_effect_guard.py +0 -0
  246. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_return_default_safety.py +0 -0
  247. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_row_batch_update.py +0 -0
  248. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_row_cache_staleness.py +0 -0
  249. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_schema_migrations.py +0 -0
  250. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_security_hardening.py +0 -0
  251. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_server_cursor.py +0 -0
  252. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_single_autocommit_safety.py +0 -0
  253. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_spreadsheet_functions.py +0 -0
  254. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_sqlite_backend.py +0 -0
  255. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_sqs_per_record_transactions.py +0 -0
  256. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_ssm_config.py +0 -0
  257. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_store_user_data.py +0 -0
  258. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  259. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_table_alter.py +0 -0
  260. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_transaction_class_wrapping.py +0 -0
  261. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_transaction_commit_and_ownership.py +0 -0
  262. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_transaction_edge_cases.py +0 -0
  263. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_where_clause_validation.py +0 -0
  264. {velocity_python-0.1.98 → velocity_python-0.1.99}/tests/test_write_hook_create_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.98
3
+ Version: 0.1.99
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.98"
7
+ version = "0.1.99"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.98"
1
+ __version__ = version = "0.1.99"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -0,0 +1,173 @@
1
+ """Outbound email over Amazon SES.
2
+
3
+ The counterpart to ``velocity.aws.sms``: same shape, same injectable-client
4
+ pattern, same "lazily construct boto3 on first use" behaviour.
5
+
6
+ Why this exists alongside the SMTP sender in ``velocity.misc.mail``: SES
7
+ authenticates with the caller's **IAM role**, so there is no password to store,
8
+ rotate, obfuscate, or leak. An SMTP relay needs a credential kept somewhere,
9
+ and "somewhere" is where credentials rot — in a config key, in a database
10
+ column, in two places that drift apart.
11
+
12
+ Messages are handed to SES as raw MIME (``Content.Raw``) rather than as the
13
+ simple subject/body structure, because ``velocity.misc.mail.build_message``
14
+ already produces a correct ``EmailMessage`` — multipart/alternative, Reply-To,
15
+ attachments and all — and re-expressing that in SES's simple format would only
16
+ lose fidelity.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from email.message import EmailMessage
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ __all__ = ["EmailClient", "SesError"]
28
+
29
+
30
+ class SesError(Exception):
31
+ """Raised when SES rejects a message."""
32
+
33
+
34
+ def _header_addresses(message: EmailMessage, header: str) -> List[str]:
35
+ value = message.get(header)
36
+ if not value:
37
+ return []
38
+ return [part.strip() for part in str(value).split(",") if part.strip()]
39
+
40
+
41
+ class EmailClient:
42
+ """Outbound email via Amazon SES (v2).
43
+
44
+ ``sender`` is the default From address and must be a verified SES identity
45
+ (or belong to a verified domain). A per-call ``sender`` overrides it.
46
+
47
+ ``configuration_set`` is how bounce and complaint events get published —
48
+ set one up pointing at an SNS topic and SES will report what happened to
49
+ each message, which is the part SMTP never told you.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ sender: Optional[str] = None,
55
+ configuration_set: Optional[str] = None,
56
+ client=None,
57
+ region: Optional[str] = None,
58
+ ):
59
+ self.sender = sender
60
+ self.configuration_set = configuration_set
61
+ self.region = region
62
+ self._client = client
63
+
64
+ @property
65
+ def client(self):
66
+ if self._client is None:
67
+ import boto3
68
+
69
+ kwargs = {"region_name": self.region} if self.region else {}
70
+ self._client = boto3.client("sesv2", **kwargs)
71
+ return self._client
72
+
73
+ def send_message(
74
+ self,
75
+ message: EmailMessage,
76
+ sender: Optional[str] = None,
77
+ configuration_set: Optional[str] = None,
78
+ ) -> str:
79
+ """Deliver an already-built ``EmailMessage``; returns the SES message id.
80
+
81
+ Bcc recipients are passed to SES as destinations and the Bcc header is
82
+ removed from the transmitted MIME — a Bcc header left in place is
83
+ visible to every recipient, which defeats the point of blind copying.
84
+ """
85
+ to = _header_addresses(message, "To")
86
+ cc = _header_addresses(message, "Cc")
87
+ bcc = _header_addresses(message, "Bcc")
88
+ if not (to or cc or bcc):
89
+ raise ValueError("EmailClient.send_message: message has no recipients")
90
+
91
+ if bcc:
92
+ del message["Bcc"]
93
+
94
+ from_address = str(sender or message.get("From") or self.sender or "").strip()
95
+ if not from_address:
96
+ raise ValueError(
97
+ "EmailClient.send_message: no sender given and the client has no default"
98
+ )
99
+
100
+ params: Dict[str, Any] = {
101
+ "FromEmailAddress": from_address,
102
+ "Destination": {"ToAddresses": to},
103
+ "Content": {"Raw": {"Data": message.as_bytes()}},
104
+ }
105
+ if cc:
106
+ params["Destination"]["CcAddresses"] = cc
107
+ if bcc:
108
+ params["Destination"]["BccAddresses"] = bcc
109
+ configuration_set = configuration_set or self.configuration_set
110
+ if configuration_set:
111
+ params["ConfigurationSetName"] = configuration_set
112
+
113
+ try:
114
+ response = self.client.send_email(**params)
115
+ except Exception as exc: # surface SES's own message, typed
116
+ raise SesError(f"SES rejected the message: {exc}") from exc
117
+ return response.get("MessageId", "")
118
+
119
+ def send(
120
+ self,
121
+ subject: str,
122
+ recipient: Any,
123
+ body: Optional[str] = None,
124
+ html: Optional[str] = None,
125
+ sender: Optional[str] = None,
126
+ cc: Any = None,
127
+ bcc: Any = None,
128
+ reply_to: Any = None,
129
+ attachments: Optional[Dict[str, Any]] = None,
130
+ headers: Optional[Dict[str, str]] = None,
131
+ configuration_set: Optional[str] = None,
132
+ ) -> str:
133
+ """Build and deliver one message; returns the SES message id."""
134
+ # Imported here rather than at module scope: misc.mail imports nothing
135
+ # from aws, and keeping the dependency one-directional avoids a cycle.
136
+ from velocity.misc.mail import build_message
137
+
138
+ from_address = str(sender or self.sender or "").strip()
139
+ if not from_address:
140
+ raise ValueError(
141
+ "EmailClient.send: no sender given and the client has no default "
142
+ "(set MailFrom, or pass sender=)"
143
+ )
144
+ message = build_message(
145
+ subject=subject,
146
+ sender=from_address,
147
+ recipient=recipient,
148
+ body=body,
149
+ html=html,
150
+ cc=cc,
151
+ bcc=bcc,
152
+ reply_to=reply_to,
153
+ attachments=attachments,
154
+ headers=headers,
155
+ )
156
+ return self.send_message(message, configuration_set=configuration_set)
157
+
158
+ @classmethod
159
+ def from_env(cls, **overrides) -> "EmailClient":
160
+ """Build from config: ``MailFrom``, ``MailConfigurationSet``, ``AWS_REGION``."""
161
+ from velocity.aws import ssm_config
162
+
163
+ sender = overrides.pop("sender", None) or ssm_config.getenv("MailFrom")
164
+ configuration_set = overrides.pop(
165
+ "configuration_set", None
166
+ ) or ssm_config.getenv("MailConfigurationSet")
167
+ region = overrides.pop("region", None) or ssm_config.get_region(None)
168
+ return cls(
169
+ sender=sender,
170
+ configuration_set=configuration_set,
171
+ region=region,
172
+ **overrides,
173
+ )
@@ -0,0 +1,344 @@
1
+ """Unit tests for velocity.aws.email (SES) and the transport selection in
2
+ velocity.misc.mail.sendmail.
3
+
4
+ Nothing here talks to AWS: EmailClient takes an injectable client, the same way
5
+ SmsClient does.
6
+ """
7
+
8
+ import unittest
9
+ from email import message_from_bytes
10
+ from email import policy
11
+
12
+ from velocity.aws.email import EmailClient, SesError
13
+ from velocity.misc.mail import (
14
+ TRANSPORT_SES,
15
+ TRANSPORT_SMTP,
16
+ build_message,
17
+ resolve_transport,
18
+ sendmail,
19
+ )
20
+
21
+
22
+ class FakeSes:
23
+ """Records what would have been handed to SES."""
24
+
25
+ def __init__(self, error=None):
26
+ self.calls = []
27
+ self.error = error
28
+
29
+ def send_email(self, **params):
30
+ self.calls.append(params)
31
+ if self.error:
32
+ raise self.error
33
+ return {"MessageId": "ses-message-id-1"}
34
+
35
+ # Convenience: the raw MIME of the most recent call, parsed back.
36
+ def last_message(self):
37
+ # policy=default so the round trip yields an EmailMessage (with
38
+ # iter_attachments), not a legacy compat32 Message.
39
+ return message_from_bytes(
40
+ self.calls[-1]["Content"]["Raw"]["Data"], policy=policy.default
41
+ )
42
+
43
+
44
+ class FakeTable:
45
+ def __init__(self, inserts):
46
+ self.inserts = inserts
47
+
48
+ def insert(self, data):
49
+ self.inserts.append(dict(data))
50
+ return dict(data)
51
+
52
+
53
+ class FakeTx:
54
+ def __init__(self):
55
+ self.inserts = []
56
+
57
+ def table(self, name):
58
+ return FakeTable(self.inserts)
59
+
60
+
61
+ class TestEmailClient(unittest.TestCase):
62
+ def client(self, **kwargs):
63
+ ses = FakeSes(error=kwargs.pop("error", None))
64
+ return EmailClient(client=ses, **kwargs), ses
65
+
66
+ def test_send_delivers_raw_mime_with_destinations(self):
67
+ client, ses = self.client(sender="Tenspace <info@example.com>")
68
+ message_id = client.send(
69
+ subject="Hello",
70
+ recipient="visitor@example.com",
71
+ body="Plain",
72
+ html="<p>Rich</p>",
73
+ )
74
+ self.assertEqual(message_id, "ses-message-id-1")
75
+ params = ses.calls[0]
76
+ self.assertEqual(params["FromEmailAddress"], "Tenspace <info@example.com>")
77
+ self.assertEqual(params["Destination"]["ToAddresses"], ["visitor@example.com"])
78
+ parsed = ses.last_message()
79
+ self.assertEqual(parsed["Subject"], "Hello")
80
+ self.assertTrue(parsed.is_multipart())
81
+
82
+ def test_reply_to_and_attachments_survive_the_raw_round_trip(self):
83
+ client, ses = self.client(sender="info@example.com")
84
+ client.send(
85
+ subject="Hello",
86
+ recipient="visitor@example.com",
87
+ body="Plain",
88
+ reply_to="sales@example.com",
89
+ attachments={"note.txt": b"file body"},
90
+ )
91
+ parsed = ses.last_message()
92
+ self.assertEqual(parsed["Reply-To"], "sales@example.com")
93
+ self.assertEqual(
94
+ [p.get_filename() for p in parsed.iter_attachments()], ["note.txt"]
95
+ )
96
+
97
+ def test_bcc_is_a_destination_but_never_a_transmitted_header(self):
98
+ # A Bcc header left in the MIME is shown to every recipient, which is
99
+ # precisely what a blind copy must not do.
100
+ client, ses = self.client(sender="info@example.com")
101
+ client.send(
102
+ subject="Hello",
103
+ recipient="visitor@example.com",
104
+ body="Plain",
105
+ cc="cc@example.com",
106
+ bcc="secret@example.com",
107
+ )
108
+ params = ses.calls[0]
109
+ self.assertEqual(params["Destination"]["CcAddresses"], ["cc@example.com"])
110
+ self.assertEqual(params["Destination"]["BccAddresses"], ["secret@example.com"])
111
+ parsed = ses.last_message()
112
+ self.assertIsNone(parsed["Bcc"])
113
+ self.assertEqual(parsed["Cc"], "cc@example.com")
114
+
115
+ def test_configuration_set_is_passed_when_set(self):
116
+ client, ses = self.client(
117
+ sender="info@example.com", configuration_set="tracking"
118
+ )
119
+ client.send(subject="Hello", recipient="a@example.com", body="x")
120
+ self.assertEqual(ses.calls[0]["ConfigurationSetName"], "tracking")
121
+
122
+ def test_no_configuration_set_key_when_unset(self):
123
+ client, ses = self.client(sender="info@example.com")
124
+ client.send(subject="Hello", recipient="a@example.com", body="x")
125
+ self.assertNotIn("ConfigurationSetName", ses.calls[0])
126
+
127
+ def test_sender_is_required(self):
128
+ client, _ = self.client()
129
+ with self.assertRaises(ValueError):
130
+ client.send(subject="Hello", recipient="a@example.com", body="x")
131
+
132
+ def test_message_without_recipients_is_rejected(self):
133
+ client, _ = self.client(sender="info@example.com")
134
+ message = build_message(
135
+ subject="s", sender="info@example.com", recipient="a@example.com", body="x"
136
+ )
137
+ del message["To"]
138
+ with self.assertRaises(ValueError):
139
+ client.send_message(message)
140
+
141
+ def test_ses_failures_are_typed(self):
142
+ client, _ = self.client(
143
+ sender="info@example.com", error=RuntimeError("Throttled")
144
+ )
145
+ with self.assertRaises(SesError) as caught:
146
+ client.send(subject="Hello", recipient="a@example.com", body="x")
147
+ self.assertIn("Throttled", str(caught.exception))
148
+
149
+
150
+ class TestTransportSelection(unittest.TestCase):
151
+ def test_explicit_argument_wins(self):
152
+ self.assertEqual(resolve_transport("ses"), TRANSPORT_SES)
153
+ self.assertEqual(resolve_transport("SMTP"), TRANSPORT_SMTP)
154
+
155
+ def test_sendmail_routes_to_ses_and_logs_the_send(self):
156
+ ses = FakeSes()
157
+ tx = FakeTx()
158
+ message = sendmail(
159
+ tx,
160
+ subject="Hello",
161
+ body="Plain",
162
+ html="<p>Rich</p>",
163
+ sender="Tenspace <info@example.com>",
164
+ recipient="visitor@example.com",
165
+ reply_to="visitor@example.com",
166
+ transport="ses",
167
+ client=ses,
168
+ log_table="sys_log_email",
169
+ )
170
+ self.assertEqual(len(ses.calls), 1)
171
+ self.assertEqual(message["Subject"], "Hello")
172
+ self.assertEqual(len(tx.inserts), 1)
173
+ self.assertEqual(tx.inserts[0]["recipient"], "visitor@example.com")
174
+
175
+ def test_ses_transport_needs_no_smtp_credential(self):
176
+ # The point of the migration: no email_settings row, no Mail* password,
177
+ # no tx required for configuration.
178
+ ses = FakeSes()
179
+ sendmail(
180
+ None,
181
+ subject="Hello",
182
+ body="Plain",
183
+ sender="info@example.com",
184
+ recipient="a@example.com",
185
+ transport="ses",
186
+ client=ses,
187
+ )
188
+ self.assertEqual(len(ses.calls), 1)
189
+
190
+ def test_ses_is_the_default_transport(self):
191
+ import os
192
+
193
+ saved = os.environ.get("MailTransport")
194
+ os.environ.pop("MailTransport", None)
195
+ os.environ["VELOCITY_SSM_ENABLED"] = "false"
196
+ try:
197
+ from velocity.aws import ssm_config
198
+
199
+ ssm_config.clear_cache()
200
+ self.assertEqual(resolve_transport(), TRANSPORT_SES)
201
+ finally:
202
+ if saved is not None:
203
+ os.environ["MailTransport"] = saved
204
+
205
+ def test_mail_transport_config_key_pins_smtp(self):
206
+ import os
207
+
208
+ saved = os.environ.get("MailTransport")
209
+ os.environ["MailTransport"] = "smtp"
210
+ os.environ["VELOCITY_SSM_ENABLED"] = "false"
211
+ try:
212
+ from velocity.aws import ssm_config
213
+
214
+ ssm_config.clear_cache()
215
+ self.assertEqual(resolve_transport(), TRANSPORT_SMTP)
216
+ finally:
217
+ if saved is None:
218
+ os.environ.pop("MailTransport", None)
219
+ else:
220
+ os.environ["MailTransport"] = saved
221
+
222
+ def test_mail_transport_config_key_selects_ses(self):
223
+ import os
224
+
225
+ saved = os.environ.get("MailTransport")
226
+ os.environ["MailTransport"] = "ses"
227
+ os.environ["VELOCITY_SSM_ENABLED"] = "false"
228
+ try:
229
+ from velocity.aws import ssm_config
230
+
231
+ ssm_config.clear_cache()
232
+ self.assertEqual(resolve_transport(), TRANSPORT_SES)
233
+ finally:
234
+ if saved is None:
235
+ os.environ.pop("MailTransport", None)
236
+ else:
237
+ os.environ["MailTransport"] = saved
238
+
239
+
240
+ class TestSesFallsBackToSmtp(unittest.TestCase):
241
+ """SES is the default; SMTP catches it when SES cannot deliver."""
242
+
243
+ def smtp_config(self):
244
+ from velocity.misc.mail import SmtpConfig
245
+
246
+ return SmtpConfig(
247
+ host="smtp.example.com",
248
+ username="test-user@example.com",
249
+ password="test-password",
250
+ sender="Tenspace <info@example.com>",
251
+ )
252
+
253
+ def test_ses_failure_delivers_over_smtp(self):
254
+ from velocity.misc.tests.test_mail_send import FakeSmtp
255
+
256
+ ses = FakeSes(error=RuntimeError("Throttled"))
257
+ smtp = FakeSmtp()
258
+ with self.assertLogs("velocity.misc.mail", level="WARNING"):
259
+ message = sendmail(
260
+ None,
261
+ subject="Hello",
262
+ body="Plain",
263
+ sender="info@example.com",
264
+ recipient="a@example.com",
265
+ transport="ses",
266
+ client=ses,
267
+ config=self.smtp_config(),
268
+ connection_factory=lambda _c: smtp,
269
+ )
270
+ self.assertEqual(len(ses.calls), 1)
271
+ self.assertEqual(len(smtp.sent), 1)
272
+ self.assertEqual(message["Subject"], "Hello")
273
+
274
+ def test_smtp_is_untouched_when_ses_succeeds(self):
275
+ from velocity.misc.tests.test_mail_send import FakeSmtp
276
+
277
+ ses = FakeSes()
278
+ smtp = FakeSmtp()
279
+ sendmail(
280
+ None,
281
+ subject="Hello",
282
+ body="Plain",
283
+ sender="info@example.com",
284
+ recipient="a@example.com",
285
+ transport="ses",
286
+ client=ses,
287
+ config=self.smtp_config(),
288
+ connection_factory=lambda _c: smtp,
289
+ )
290
+ self.assertEqual(len(ses.calls), 1)
291
+ self.assertEqual(smtp.sent, [])
292
+
293
+ def test_both_failing_reports_both_causes(self):
294
+ from velocity.misc.mail import MailDeliveryError
295
+
296
+ ses = FakeSes(error=RuntimeError("Throttled"))
297
+
298
+ def exploding_smtp(_config):
299
+ from velocity.misc.tests.test_mail_send import FakeSmtp
300
+
301
+ smtp = FakeSmtp()
302
+
303
+ def boom(*_a, **_k):
304
+ raise RuntimeError("relay refused")
305
+
306
+ smtp.sendmail = boom
307
+ return smtp
308
+
309
+ with self.assertLogs("velocity.misc.mail", level="WARNING"):
310
+ with self.assertRaises(MailDeliveryError) as caught:
311
+ sendmail(
312
+ None,
313
+ subject="Hello",
314
+ body="Plain",
315
+ sender="info@example.com",
316
+ recipient="a@example.com",
317
+ transport="ses",
318
+ client=ses,
319
+ config=self.smtp_config(),
320
+ connection_factory=exploding_smtp,
321
+ )
322
+ text = str(caught.exception)
323
+ self.assertIn("Throttled", text)
324
+ self.assertIn("relay refused", text)
325
+
326
+ def test_fallback_can_be_disabled(self):
327
+ ses = FakeSes(error=RuntimeError("Throttled"))
328
+ with self.assertRaises(Exception) as caught:
329
+ sendmail(
330
+ None,
331
+ subject="Hello",
332
+ body="Plain",
333
+ sender="info@example.com",
334
+ recipient="a@example.com",
335
+ transport="ses",
336
+ client=ses,
337
+ config=self.smtp_config(),
338
+ fallback=False,
339
+ )
340
+ self.assertIn("Throttled", str(caught.exception))
341
+
342
+
343
+ if __name__ == "__main__":
344
+ unittest.main()