velocity-python 0.1.53__tar.gz → 0.1.54__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 (194) hide show
  1. {velocity_python-0.1.53/src/velocity_python.egg-info → velocity_python-0.1.54}/PKG-INFO +10 -1
  2. {velocity_python-0.1.53 → velocity_python-0.1.54}/pyproject.toml +13 -1
  3. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/context.py +6 -0
  5. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/lambda_handler.py +5 -2
  6. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/response.py +10 -2
  7. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/ssm_config.py +7 -2
  8. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +16 -27
  9. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/tests/test_response.py +2 -2
  10. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/decorators.py +44 -3
  11. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/engine.py +50 -4
  12. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/table.py +3 -3
  13. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/transaction.py +82 -51
  14. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/postgres/__init__.py +20 -1
  15. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +14 -1
  16. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/sql/test_postgres_select_variances.py +14 -1
  17. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_postgres.py +3 -1
  18. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_schema_locking_initializers.py +7 -7
  19. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_sql_builder.py +8 -0
  20. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_view_helper.py +10 -1
  21. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/conv/oconv.py +8 -2
  22. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/format.py +1 -1
  23. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/authorizenet_adapter.py +25 -0
  24. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/braintree_adapter.py +16 -0
  25. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/stripe_adapter.py +28 -6
  26. {velocity_python-0.1.53 → velocity_python-0.1.54/src/velocity_python.egg-info}/PKG-INFO +10 -1
  27. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity_python.egg-info/SOURCES.txt +3 -0
  28. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity_python.egg-info/requires.txt +9 -0
  29. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_amplify_build.py +4 -2
  30. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_assets_service.py +3 -1
  31. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_concurrency_safety.py +4 -1
  32. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_connection_pool.py +49 -0
  33. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_connection_resilience.py +52 -0
  34. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_context_job_descriptions.py +3 -1
  35. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_decorators.py +73 -1
  36. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_payment_stripe_adapter.py +52 -0
  37. velocity_python-0.1.54/tests/test_retry_side_effect_guard.py +86 -0
  38. velocity_python-0.1.54/tests/test_return_default_safety.py +78 -0
  39. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_row_cache_staleness.py +2 -0
  40. velocity_python-0.1.54/tests/test_single_autocommit_safety.py +80 -0
  41. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_sys_modified_count_postgres_demo.py +16 -0
  42. {velocity_python-0.1.53 → velocity_python-0.1.54}/LICENSE +0 -0
  43. {velocity_python-0.1.53 → velocity_python-0.1.54}/README.md +0 -0
  44. {velocity_python-0.1.53 → velocity_python-0.1.54}/setup.cfg +0 -0
  45. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/__init__.py +0 -0
  46. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/amplify.py +0 -0
  47. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/amplify_build.py +0 -0
  48. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/__init__.py +0 -0
  49. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/backfill.py +0 -0
  50. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/indexing.py +0 -0
  51. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/references.py +0 -0
  52. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/service.py +0 -0
  53. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/assets/usage_index.py +0 -0
  54. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/dirty_pipeline.py +0 -0
  55. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/__init__.py +0 -0
  56. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/base_handler.py +0 -0
  57. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/context_factory.py +0 -0
  58. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/exceptions.py +0 -0
  59. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  60. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  61. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  62. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/perf.py +0 -0
  63. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  64. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/s3.py +0 -0
  65. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/tests/__init__.py +0 -0
  66. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  67. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/__init__.py +0 -0
  68. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/__init__.py +0 -0
  69. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/async_support.py +0 -0
  70. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/column.py +0 -0
  71. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/database.py +0 -0
  72. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/result.py +0 -0
  73. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/row.py +0 -0
  74. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/sequence.py +0 -0
  75. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/core/view.py +0 -0
  76. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/exceptions.py +0 -0
  77. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/migrations.py +0 -0
  78. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/__init__.py +0 -0
  79. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/base/__init__.py +0 -0
  80. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/base/initializer.py +0 -0
  81. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/base/operators.py +0 -0
  82. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/base/sql.py +0 -0
  83. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/base/types.py +0 -0
  84. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/mysql/__init__.py +0 -0
  85. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/mysql/operators.py +0 -0
  86. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/mysql/reserved.py +0 -0
  87. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/mysql/sql.py +0 -0
  88. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/mysql/types.py +0 -0
  89. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/postgres/operators.py +0 -0
  90. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/postgres/reserved.py +0 -0
  91. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/postgres/sql.py +0 -0
  92. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/postgres/types.py +0 -0
  93. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  94. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlite/operators.py +0 -0
  95. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  96. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlite/sql.py +0 -0
  97. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlite/types.py +0 -0
  98. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  99. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  100. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  101. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  102. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/sqlserver/types.py +0 -0
  103. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/servers/tablehelper.py +0 -0
  104. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/__init__.py +0 -0
  105. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/common_db_test.py +0 -0
  106. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/__init__.py +0 -0
  107. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/common.py +0 -0
  108. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_column.py +0 -0
  109. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  110. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_database.py +0 -0
  111. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  112. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  113. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  114. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_result.py +0 -0
  115. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_row.py +0 -0
  116. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  117. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  118. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  119. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  120. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  121. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_table.py +0 -0
  122. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  123. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  124. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/sql/__init__.py +0 -0
  125. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/sql/common.py +0 -0
  126. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  127. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_db_utils.py +0 -0
  128. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  129. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  130. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_result_caching.py +0 -0
  131. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  132. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  133. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  134. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/tests/test_tablehelper.py +0 -0
  135. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/db/utils.py +0 -0
  136. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/logging.py +0 -0
  137. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/__init__.py +0 -0
  138. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/conv/__init__.py +0 -0
  139. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/conv/iconv.py +0 -0
  140. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/db.py +0 -0
  141. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/export.py +0 -0
  142. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/mail.py +0 -0
  143. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/merge.py +0 -0
  144. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/pdf.py +0 -0
  145. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/__init__.py +0 -0
  146. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_db.py +0 -0
  147. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_fix.py +0 -0
  148. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_format.py +0 -0
  149. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_iconv.py +0 -0
  150. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_merge.py +0 -0
  151. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_oconv.py +0 -0
  152. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_original_error.py +0 -0
  153. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tests/test_timer.py +0 -0
  154. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/timer.py +0 -0
  155. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/misc/tools.py +0 -0
  156. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/__init__.py +0 -0
  157. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/authorizenet_mirror.py +0 -0
  158. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/base_adapter.py +0 -0
  159. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/braintree_mirror.py +0 -0
  160. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/charge_rules.py +0 -0
  161. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity/payment/stripe_mirror.py +0 -0
  162. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  163. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity_python.egg-info/entry_points.txt +0 -0
  164. {velocity_python-0.1.53 → velocity_python-0.1.54}/src/velocity_python.egg-info/top_level.txt +0 -0
  165. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_asset_indexing.py +0 -0
  166. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_asset_references.py +0 -0
  167. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_async_support.py +0 -0
  168. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_batch_operations.py +0 -0
  169. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_dirty_pipeline_fast_path.py +0 -0
  170. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_email_processing.py +0 -0
  171. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_iconv_money_to_cents.py +0 -0
  172. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_lambda_handler.py +0 -0
  173. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_lambda_handler_auth.py +0 -0
  174. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_mixins_import.py +0 -0
  175. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_n_plus_one.py +0 -0
  176. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_observability.py +0 -0
  177. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_payment_authorizenet_adapter.py +0 -0
  178. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_payment_braintree_adapter.py +0 -0
  179. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_payment_braintree_mirror.py +0 -0
  180. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_payment_profile_sorting.py +0 -0
  181. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_pdf.py +0 -0
  182. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_prepared_statements.py +0 -0
  183. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_psycopg3_upgrade.py +0 -0
  184. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_query_cache.py +0 -0
  185. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_row_batch_update.py +0 -0
  186. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_row_dirty_tracking.py +0 -0
  187. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_schema_migrations.py +0 -0
  188. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_security_hardening.py +0 -0
  189. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_server_cursor.py +0 -0
  190. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_spreadsheet_functions.py +0 -0
  191. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_sqs_per_record_transactions.py +0 -0
  192. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_ssm_config.py +0 -0
  193. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_table_alter.py +0 -0
  194. {velocity_python-0.1.53 → velocity_python-0.1.54}/tests/test_where_clause_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.53
3
+ Version: 0.1.54
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
@@ -46,6 +46,7 @@ Requires-Dist: psycopg[binary]>=3.2.0; extra == "postgres"
46
46
  Provides-Extra: payment
47
47
  Requires-Dist: stripe>=12.0.0; extra == "payment"
48
48
  Requires-Dist: braintree>=4.30.0; extra == "payment"
49
+ Requires-Dist: xmltodict>=0.13.0; extra == "payment"
49
50
  Provides-Extra: all
50
51
  Requires-Dist: velocity-python[aws,excel,http,payment,pdf,postgres,templates]; extra == "all"
51
52
  Provides-Extra: dev
@@ -61,6 +62,14 @@ Requires-Dist: pytest>=8.0.0; extra == "test"
61
62
  Requires-Dist: pytest-cov>=6.0.0; extra == "test"
62
63
  Requires-Dist: pytest-mock>=3.14.0; extra == "test"
63
64
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "test"
65
+ Requires-Dist: psycopg[binary]>=3.2.0; extra == "test"
66
+ Requires-Dist: boto3>=1.35.0; extra == "test"
67
+ Requires-Dist: requests>=2.32.0; extra == "test"
68
+ Requires-Dist: stripe>=12.0.0; extra == "test"
69
+ Requires-Dist: braintree>=4.30.0; extra == "test"
70
+ Requires-Dist: xmltodict>=0.13.0; extra == "test"
71
+ Requires-Dist: authorizenet>=1.1.3; extra == "test"
72
+ Requires-Dist: openpyxl>=3.1.0; extra == "test"
64
73
  Provides-Extra: docs
65
74
  Requires-Dist: sphinx>=8.0.0; extra == "docs"
66
75
  Requires-Dist: sphinx-rtd-theme>=3.0.0; extra == "docs"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.53"
7
+ version = "0.1.54"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -72,6 +72,7 @@ postgres = [
72
72
  payment = [
73
73
  "stripe>=12.0.0",
74
74
  "braintree>=4.30.0",
75
+ "xmltodict>=0.13.0",
75
76
  ]
76
77
  all = [
77
78
  "velocity-python[postgres,aws,excel,templates,http,payment,pdf]",
@@ -90,6 +91,17 @@ test = [
90
91
  "pytest-cov>=6.0.0",
91
92
  "pytest-mock>=3.14.0",
92
93
  "pytest-asyncio>=1.0.0",
94
+ # Drivers/SDKs the test suite imports directly. Without these the suite
95
+ # silently degrades to collection errors (see R29). Keep in sync with the
96
+ # production layer (psycopg v3 is the sole Postgres driver).
97
+ "psycopg[binary]>=3.2.0",
98
+ "boto3>=1.35.0",
99
+ "requests>=2.32.0",
100
+ "stripe>=12.0.0",
101
+ "braintree>=4.30.0",
102
+ "xmltodict>=0.13.0",
103
+ "authorizenet>=1.1.3",
104
+ "openpyxl>=3.1.0",
93
105
  ]
94
106
  docs = [
95
107
  "sphinx>=8.0.0",
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.53"
1
+ __version__ = version = "0.1.54"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -422,6 +422,12 @@ class Context:
422
422
  "job_id": id,
423
423
  "status": "Initialized",
424
424
  "message": "Job Initialized",
425
+ # Carry a caller-supplied human-readable description onto
426
+ # the job activity row (None when omitted) so scheduled /
427
+ # program-object jobs are identifiable in the activity log.
428
+ "description": item.get("description")
429
+ if isinstance(item, dict)
430
+ else None,
425
431
  }
426
432
  )
427
433
  messages.append({"Id": id, "MessageBody": to_json(message)})
@@ -41,8 +41,11 @@ class LambdaHandler(BaseHandler):
41
41
  )
42
42
 
43
43
  def beforeAction(self, tx, context):
44
- # Enhanced activity tracking
45
- self._enhanced_before_action(tx, context)
44
+ # Enhanced activity tracking is supplied by the WebHandler mixin; a bare
45
+ # LambdaHandler (no WebHandler) simply skips it rather than crashing.
46
+ enhanced = getattr(self, "_enhanced_before_action", None)
47
+ if callable(enhanced):
48
+ enhanced(tx, context)
46
49
  logger.debug("starting LamdaHandler.beforeAction")
47
50
 
48
51
  self.current_user = {}
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import sys
2
3
  import time
3
4
  import traceback
@@ -5,6 +6,11 @@ from typing import Any, Dict, List, Optional
5
6
  from velocity.misc.format import to_json
6
7
 
7
8
 
9
+ # When DEBUG is off, exception responses omit the traceback so server-side
10
+ # stack traces are not leaked to API clients. Enable with VELOCITY_DEBUG=true.
11
+ DEBUG = os.environ.get("VELOCITY_DEBUG", "").lower() in ("true", "1", "yes")
12
+
13
+
8
14
  class Response:
9
15
  """Class to manage and structure HTTP responses with various actions and custom headers."""
10
16
 
@@ -207,8 +213,10 @@ class Response:
207
213
  "python_exception": {
208
214
  "type": str(exc_type),
209
215
  "value": str(exc_value),
210
- "traceback": traceback.format_exc(),
211
- "tb": traceback.format_tb(tb) if tb else [],
216
+ # Only expose the traceback when DEBUG is enabled; otherwise
217
+ # we would leak server internals to the API client.
218
+ "traceback": traceback.format_exc() if DEBUG else None,
219
+ "tb": traceback.format_tb(tb) if (DEBUG and tb) else [],
212
220
  }
213
221
  }
214
222
  )
@@ -80,12 +80,17 @@ def _is_lambda_runtime() -> bool:
80
80
  return _lambda_function_name() is not None or os.environ.get('AWS_EXECUTION_ENV', '').startswith('AWS_Lambda_')
81
81
 
82
82
 
83
+ # Stage identity: AppStage is canonical; ENV / USER_BRANCH / AWS_BRANCH are
84
+ # retired compatibility aliases (AppStage wins when more than one is set).
85
+ _STAGE_ENV_KEYS = ('AppStage', 'AWS_BRANCH', 'ENV', 'USER_BRANCH')
86
+
87
+
83
88
  def _infer_project_name_from_lambda_name() -> str | None:
84
89
  function_name = _lambda_function_name()
85
90
  if function_name is None:
86
91
  return None
87
92
 
88
- stage = _first_nonempty_env('AppStage', 'AWS_BRANCH')
93
+ stage = _first_nonempty_env(*_STAGE_ENV_KEYS)
89
94
  base_name = function_name
90
95
  if stage:
91
96
  suffix = f'-{stage}'
@@ -106,7 +111,7 @@ def get_project_name(default: Optional[str] = None) -> Optional[str]:
106
111
 
107
112
 
108
113
  def get_stage(default: Optional[str] = None) -> Optional[str]:
109
- return _first_nonempty_env('AppStage', 'AWS_BRANCH') or default
114
+ return _first_nonempty_env(*_STAGE_ENV_KEYS) or default
110
115
 
111
116
 
112
117
  def get_region(default: Optional[str] = 'us-east-1') -> Optional[str]:
@@ -46,37 +46,26 @@ class TestLambdaHandlerJSONSerialization(unittest.TestCase):
46
46
  # Create handler
47
47
  handler = LambdaHandler(self.test_event, self.test_context)
48
48
 
49
- # Mock the transaction decorator to pass through tx
50
- with patch("velocity.aws.handlers.lambda_handler.engine") as mock_engine:
49
+ # serve() receives the transaction directly (the @engine.transaction
50
+ # decorator supplies it in production); a mock stands in here.
51
+ result = handler.serve(MagicMock())
51
52
 
52
- def mock_transaction(func):
53
- def wrapper(*args, **kwargs):
54
- mock_tx = MagicMock()
55
- return func(mock_tx, *args, **kwargs)
53
+ # Verify result is a dictionary (JSON-serializable)
54
+ self.assertIsInstance(result, dict)
56
55
 
57
- return wrapper
56
+ # Verify it has the expected Lambda response structure
57
+ self.assertIn("statusCode", result)
58
+ self.assertIn("headers", result)
59
+ self.assertIn("body", result)
58
60
 
59
- mock_engine.transaction = mock_transaction
61
+ # Verify the body is a JSON string
62
+ self.assertIsInstance(result["body"], str)
60
63
 
61
- # Call serve method
62
- result = handler.serve(MagicMock())
63
-
64
- # Verify result is a dictionary (JSON-serializable)
65
- self.assertIsInstance(result, dict)
66
-
67
- # Verify it has the expected Lambda response structure
68
- self.assertIn("statusCode", result)
69
- self.assertIn("headers", result)
70
- self.assertIn("body", result)
71
-
72
- # Verify the body is a JSON string
73
- self.assertIsInstance(result["body"], str)
74
-
75
- # Verify the entire result can be JSON serialized
76
- try:
77
- json.dumps(result)
78
- except (TypeError, ValueError) as e:
79
- self.fail(f"Result is not JSON serializable: {e}")
64
+ # Verify the entire result can be JSON serialized
65
+ try:
66
+ json.dumps(result)
67
+ except (TypeError, ValueError) as e:
68
+ self.fail(f"Result is not JSON serializable: {e}")
80
69
 
81
70
  def test_response_object_has_render_method(self):
82
71
  """Test that Response object has a proper render method."""
@@ -105,7 +105,7 @@ class TestResponse(unittest.TestCase):
105
105
  self.assertEqual(self.response.actions[0]["payload"], payload)
106
106
 
107
107
  def test_exception_handling_debug_on(self):
108
- with patch("your_module.DEBUG", True), patch(
108
+ with patch("velocity.aws.handlers.response.DEBUG", True), patch(
109
109
  "traceback.format_exc", return_value="formatted traceback"
110
110
  ):
111
111
  try:
@@ -119,7 +119,7 @@ class TestResponse(unittest.TestCase):
119
119
  self.assertEqual(exception_info["traceback"], "formatted traceback")
120
120
 
121
121
  def test_exception_handling_debug_off(self):
122
- with patch("your_module.DEBUG", False), patch(
122
+ with patch("velocity.aws.handlers.response.DEBUG", False), patch(
123
123
  "traceback.format_exc", return_value="formatted traceback"
124
124
  ):
125
125
  try:
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import os
2
3
  import time
3
4
  import random
4
5
  from collections.abc import Mapping, Sequence
@@ -8,6 +9,19 @@ from velocity.db import exceptions
8
9
  logger = logging.getLogger("velocity.db")
9
10
 
10
11
 
12
+ def _raise_on_db_error():
13
+ """R22 — opt-in strict mode.
14
+
15
+ When ``VELOCITY_RAISE_ON_DB_ERROR`` is truthy, ``@return_default`` re-raises
16
+ the underlying DB exception instead of silently swallowing it and returning a
17
+ default. Off by default to preserve historical behaviour; turn it on in an
18
+ environment that would rather see DB errors than empty/zero results.
19
+ """
20
+ return os.environ.get("VELOCITY_RAISE_ON_DB_ERROR", "").lower() in (
21
+ "true", "1", "yes",
22
+ )
23
+
24
+
11
25
  _PRIMARY_KEY_PATTERNS = (
12
26
  "primary key",
13
27
  "key 'primary'",
@@ -76,7 +90,15 @@ def reset_id_on_dup_key(func):
76
90
  if retries < max_retries:
77
91
  backoff_time = (2**retries) * 0.01 + random.uniform(0, 0.02)
78
92
  time.sleep(backoff_time)
79
- self.set_sequence(self.max("sys_id") + 1)
93
+ # R23 — Resolve the true MAX(sys_id) WITHOUT going through the
94
+ # @return_default(0) wrapper on Table.max(). If max() silently
95
+ # swallowed an error and returned 0, set_sequence(1) would reset
96
+ # a populated table's sequence and guarantee future PK
97
+ # collisions. Run the aggregate directly so any error surfaces
98
+ # instead of corrupting the sequence.
99
+ sql, vals = self.max("sys_id", sql_only=True)
100
+ current_max = self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
101
+ self.set_sequence((current_max or 0) + 1)
80
102
  return reset_decorator(self, *args, retries=retries + 1, **kwds)
81
103
  raise exceptions.DbDuplicateKeyError("Max retries reached.")
82
104
 
@@ -93,15 +115,29 @@ def return_default(
93
115
  exceptions.DbTruncationError,
94
116
  exceptions.DbObjectExistsError,
95
117
  ),
118
+ default_factory=None,
96
119
  ):
97
120
  """
98
121
  If the wrapped function raises one of the specified exceptions, or returns None,
99
122
  this decorator returns the `default` value instead.
123
+
124
+ Use ``default_factory`` (a zero-arg callable) instead of ``default`` whenever
125
+ the default is **mutable** (e.g. ``Result()``). A bare ``default=Result()``
126
+ evaluates once at import and would be aliased across every swallowed call,
127
+ so one caller mutating/iterating it would corrupt the next caller's result
128
+ (R22). ``default_factory`` produces a fresh value per call.
129
+
130
+ When ``VELOCITY_RAISE_ON_DB_ERROR`` is set, the swallow is bypassed and the
131
+ underlying exception propagates (see :func:`_raise_on_db_error`).
100
132
  """
101
133
 
134
+ def _make_default():
135
+ return default_factory() if default_factory is not None else default
136
+
102
137
  def decorator(func):
103
138
  func.default = default
104
139
  func.exceptions = exceptions
140
+ func.default_factory = default_factory
105
141
 
106
142
  @wraps(func)
107
143
  def wrapper(self, *args, **kwds):
@@ -109,10 +145,15 @@ def return_default(
109
145
  try:
110
146
  result = func(self, *args, **kwds)
111
147
  if result is None:
112
- result = default
148
+ result = _make_default()
113
149
  except func.exceptions as e:
114
150
  self.tx.rollback_savepoint(sp, cursor=self.cursor())
115
151
 
152
+ # R22 — opt-in strict mode: surface the DB error instead of
153
+ # silently degrading to a default value.
154
+ if _raise_on_db_error():
155
+ raise
156
+
116
157
  # Log the swallowed exception so silent failures are visible.
117
158
  logger.warning(
118
159
  "@return_default swallowed %s in %s.%s: %s",
@@ -140,7 +181,7 @@ def return_default(
140
181
  }
141
182
  except Exception:
142
183
  pass
143
- return default
184
+ return _make_default()
144
185
  self.tx.release_savepoint(sp, cursor=self.cursor())
145
186
  return result
146
187
 
@@ -61,6 +61,20 @@ def _build_error_code_map(sql_dialect) -> dict:
61
61
  _ENGINE_FILE = os.path.normpath(__file__)
62
62
 
63
63
 
64
+ def _retry_blocked_by_side_effect(tx):
65
+ """R21 — return True (and log) when *tx* has performed an irreversible
66
+ external side effect, so the auto-retry envelope must re-raise instead of
67
+ re-running the function (which would repeat the side effect)."""
68
+ if getattr(tx, "_external_side_effect", False):
69
+ logger.error(
70
+ "Refusing to auto-retry a transaction that already performed an "
71
+ "external side effect (e.g. a payment charge); re-raising to avoid "
72
+ "a duplicate side effect (R21)."
73
+ )
74
+ return True
75
+ return False
76
+
77
+
64
78
  class ConnectionPool:
65
79
  """
66
80
  A simple thread-safe connection pool for database connections.
@@ -75,7 +89,8 @@ class ConnectionPool:
75
89
  transparently.
76
90
  """
77
91
 
78
- def __init__(self, connect_fn, minconn=1, maxconn=5, *, validate=True):
92
+ def __init__(self, connect_fn, minconn=1, maxconn=5, *, validate=True,
93
+ acquire_timeout=None):
79
94
  """
80
95
  Args:
81
96
  connect_fn: Zero-argument callable that returns a new DB-API connection.
@@ -84,6 +99,11 @@ class ConnectionPool:
84
99
  validate: When True, run a lightweight check before handing out a
85
100
  connection. Disable when behind pgbouncer in transaction
86
101
  mode to avoid an extra round-trip.
102
+ acquire_timeout: Max seconds to wait for a free slot when the pool is
103
+ exhausted before raising ``DbConnectionError``. ``None``
104
+ or <= 0 blocks indefinitely (legacy behaviour). A bounded
105
+ default lets callers fail fast instead of hanging until the
106
+ surrounding Lambda/request times out (R24).
87
107
  """
88
108
  if maxconn < 1:
89
109
  raise ValueError("maxconn must be >= 1")
@@ -96,6 +116,7 @@ class ConnectionPool:
96
116
  self._minconn = minconn
97
117
  self._maxconn = maxconn
98
118
  self._validate = validate
119
+ self._acquire_timeout = acquire_timeout if (acquire_timeout and acquire_timeout > 0) else None
99
120
 
100
121
  self._lock = threading.Lock()
101
122
  self._available = threading.Semaphore(maxconn)
@@ -124,7 +145,16 @@ class ConnectionPool:
124
145
  raise exceptions.DbConnectionError("Connection pool is closed")
125
146
 
126
147
  t0 = time.perf_counter()
127
- self._available.acquire() # blocks when maxconn reached
148
+ # R24 bounded wait for a free slot. Without a timeout an exhausted
149
+ # pool blocks until the surrounding Lambda/request itself times out,
150
+ # which masks the real problem and wastes the whole invocation window.
151
+ acquired = self._available.acquire(timeout=self._acquire_timeout)
152
+ if not acquired:
153
+ raise exceptions.DbConnectionError(
154
+ f"Connection pool exhausted: no connection available within "
155
+ f"{self._acquire_timeout}s (maxconn={self._maxconn}). "
156
+ f"A connection is likely held open across a slow external call."
157
+ )
128
158
 
129
159
  with self._lock:
130
160
  # Try to reuse an idle connection.
@@ -239,7 +269,7 @@ class Engine:
239
269
 
240
270
  def __init__(self, driver, config, sql, connect_timeout=None, schema_locked=False,
241
271
  pool_min=None, pool_max=None, pool_enabled=None, pool_validate=True,
242
- prepare_enabled=None):
272
+ prepare_enabled=None, pool_acquire_timeout=None):
243
273
  self.__config = config
244
274
  self.__sql = sql
245
275
  self.__driver = driver
@@ -270,6 +300,12 @@ class Engine:
270
300
  pool_min = int(os.environ.get("VELOCITY_POOL_MIN", "1"))
271
301
  if pool_max is None:
272
302
  pool_max = int(os.environ.get("VELOCITY_POOL_MAX", "5"))
303
+ # R24 — bounded wait for a pooled connection. Default 30s; set to 0 to
304
+ # restore the legacy block-forever behaviour.
305
+ if pool_acquire_timeout is None:
306
+ pool_acquire_timeout = float(
307
+ os.environ.get("VELOCITY_POOL_ACQUIRE_TIMEOUT", "30")
308
+ )
273
309
 
274
310
  self.__pool_enabled = pool_enabled
275
311
  self.__pool: ConnectionPool | None = None
@@ -281,6 +317,7 @@ class Engine:
281
317
  minconn=pool_min,
282
318
  maxconn=pool_max,
283
319
  validate=pool_validate,
320
+ acquire_timeout=pool_acquire_timeout,
284
321
  )
285
322
  logger.info("Connection pool created (min=%d, max=%d)", pool_min, pool_max)
286
323
  except Exception as exc:
@@ -506,6 +543,8 @@ class Engine:
506
543
  try:
507
544
  return function(*args, **kwds)
508
545
  except exceptions.DbRetryTransaction:
546
+ if _retry_blocked_by_side_effect(_tx):
547
+ raise
509
548
  retry_count += 1
510
549
  if retry_count > self.MAX_RETRIES:
511
550
  raise
@@ -513,6 +552,8 @@ class Engine:
513
552
  time.sleep(min(2.0, 0.05 * (2**min(retry_count, 6))))
514
553
  _tx.rollback()
515
554
  except exceptions.DbLockTimeoutError:
555
+ if _retry_blocked_by_side_effect(_tx):
556
+ raise
516
557
  lock_timeout_count += 1
517
558
  if lock_timeout_count > self.MAX_RETRIES:
518
559
  raise
@@ -521,7 +562,12 @@ class Engine:
521
562
  continue
522
563
  except exceptions.DbConnectionError as e:
523
564
  # Transient disconnects can happen during maintenance / restarts.
524
- # Retrying the entire top-level function is the safest option.
565
+ # Retrying the entire top-level function is the safest option
566
+ # UNLESS the function already performed an irreversible external
567
+ # side effect (R21), in which case re-running it could e.g.
568
+ # double-charge a customer.
569
+ if _retry_blocked_by_side_effect(_tx):
570
+ raise
525
571
  msg = str(e).strip().lower()
526
572
  if not getattr(
527
573
  self.sql, "is_transient_connection_error_message", lambda _m: False
@@ -2262,7 +2262,7 @@ class Table:
2262
2262
  sql, vals = self.sql.select(self.tx, columns="count(*)", table=self.name)
2263
2263
  return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
2264
2264
 
2265
- @return_default(Result())
2265
+ @return_default(default_factory=Result)
2266
2266
  def select(
2267
2267
  self,
2268
2268
  columns=None,
@@ -2400,7 +2400,7 @@ class Table:
2400
2400
  raise Exception("A query generator does not support dictionary-type WHERE.")
2401
2401
  return Query(sql)
2402
2402
 
2403
- @return_default(Result())
2403
+ @return_default(default_factory=Result)
2404
2404
  def server_select(
2405
2405
  self,
2406
2406
  columns=None,
@@ -2434,7 +2434,7 @@ class Table:
2434
2434
  return sql, vals
2435
2435
  return self.tx.server_execute(sql, vals)
2436
2436
 
2437
- @return_default(Result())
2437
+ @return_default(default_factory=Result)
2438
2438
  def batch(self, size=100, *args, **kwds):
2439
2439
  """
2440
2440
  Generator that yields batches of rows (lists) of size `size`.
@@ -176,6 +176,11 @@ class Transaction:
176
176
  # R14 — N+1 detection: per-table SELECT counts.
177
177
  self._table_select_counts: dict[str, int] = {}
178
178
  self._n1_warned: set[str] = set()
179
+ # R21 — set once an irreversible external side effect (e.g. a payment
180
+ # charge/capture) has happened in this transaction. The engine's
181
+ # auto-retry envelope must NOT re-run a function after this, or a
182
+ # transient DB error post-charge would charge the customer again.
183
+ self._external_side_effect = False
179
184
 
180
185
  def __str__(self):
181
186
  config = mask_config_for_display(self.engine.config)
@@ -265,64 +270,73 @@ class Transaction:
265
270
 
266
271
  t0 = _time.perf_counter()
267
272
  try:
268
- if parms:
269
- cursor.execute(sql, parms, prepare=prepare)
270
- else:
271
- cursor.execute(sql, prepare=prepare)
272
- except TypeError:
273
- # Driver doesn't support 'prepare' keyword (non-psycopg3).
274
273
  try:
275
274
  if parms:
276
- cursor.execute(sql, parms)
275
+ cursor.execute(sql, parms, prepare=prepare)
277
276
  else:
278
- cursor.execute(sql)
277
+ cursor.execute(sql, prepare=prepare)
278
+ except TypeError:
279
+ # Driver doesn't support 'prepare' keyword (non-psycopg3).
280
+ try:
281
+ if parms:
282
+ cursor.execute(sql, parms)
283
+ else:
284
+ cursor.execute(sql)
285
+ except Exception as e:
286
+ raise self.engine.process_error(e, sql, parms)
279
287
  except Exception as e:
280
288
  raise self.engine.process_error(e, sql, parms)
281
- except Exception as e:
282
- raise self.engine.process_error(e, sql, parms)
283
-
284
- elapsed_ms = (_time.perf_counter() - t0) * 1000
285
- self._query_count += 1
286
- self._query_time_ms += elapsed_ms
287
-
288
- # R12 — Slow query logging.
289
- if _SLOW_QUERY_MS and elapsed_ms > _SLOW_QUERY_MS:
290
- op = _classify_sql(sql)
291
- tbl = _extract_table_name(sql)
292
- sql_preview = _summarize_sql(sql)
293
- _logger.warning(
294
- "Slow query (%s): %.1f ms table=%s sql=%s",
295
- op, elapsed_ms, tbl, sql_preview,
296
- extra={
297
- "query_duration_ms": round(elapsed_ms, 1),
298
- "table_name": tbl,
299
- "operation": op,
300
- "sql_preview": sql_preview,
301
- },
302
- stack_info=True,
303
- )
304
-
305
- # R14 — N+1 detection (only when debug=True).
306
- if debug and _N_PLUS_1_THRESHOLD:
307
- op = _classify_sql(sql)
308
- if op == "SELECT":
309
- tbl = _extract_table_name(sql)
310
- if tbl:
311
- self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
312
- count = self._table_select_counts[tbl]
313
- if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
314
- self._n1_warned.add(tbl)
315
- _logger.warning(
316
- "Possible N+1: table %s queried %d times in this transaction "
317
- "(threshold=%d). Consider using prefetch=True or select_rows().",
318
- tbl, count, _N_PLUS_1_THRESHOLD,
319
- extra={"table_name": tbl, "select_count": count},
320
- )
321
289
 
322
- if single:
323
- self.connection.autocommit = False
290
+ elapsed_ms = (_time.perf_counter() - t0) * 1000
291
+ self._query_count += 1
292
+ self._query_time_ms += elapsed_ms
324
293
 
325
- return Result(cursor, self, sql, parms)
294
+ # R12 Slow query logging.
295
+ if _SLOW_QUERY_MS and elapsed_ms > _SLOW_QUERY_MS:
296
+ op = _classify_sql(sql)
297
+ tbl = _extract_table_name(sql)
298
+ sql_preview = _summarize_sql(sql)
299
+ _logger.warning(
300
+ "Slow query (%s): %.1f ms table=%s sql=%s",
301
+ op, elapsed_ms, tbl, sql_preview,
302
+ extra={
303
+ "query_duration_ms": round(elapsed_ms, 1),
304
+ "table_name": tbl,
305
+ "operation": op,
306
+ "sql_preview": sql_preview,
307
+ },
308
+ stack_info=True,
309
+ )
310
+
311
+ # R14 — N+1 detection (only when debug=True).
312
+ if debug and _N_PLUS_1_THRESHOLD:
313
+ op = _classify_sql(sql)
314
+ if op == "SELECT":
315
+ tbl = _extract_table_name(sql)
316
+ if tbl:
317
+ self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
318
+ count = self._table_select_counts[tbl]
319
+ if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
320
+ self._n1_warned.add(tbl)
321
+ _logger.warning(
322
+ "Possible N+1: table %s queried %d times in this transaction "
323
+ "(threshold=%d). Consider using prefetch=True or select_rows().",
324
+ tbl, count, _N_PLUS_1_THRESHOLD,
325
+ extra={"table_name": tbl, "select_count": count},
326
+ )
327
+
328
+ return Result(cursor, self, sql, parms)
329
+ finally:
330
+ # R20 — Always restore transactional mode for single-statement
331
+ # (autocommit) execution, even when execute() raised. Otherwise a
332
+ # connection can be returned to the pool stuck in autocommit mode,
333
+ # silently turning the next borrower's commit/rollback/savepoints
334
+ # into no-ops.
335
+ if single and self.connection is not None:
336
+ try:
337
+ self.connection.autocommit = False
338
+ except Exception:
339
+ pass
326
340
 
327
341
  def _execute_values(self, sql, args_list, template=None, page_size=1000):
328
342
  """
@@ -443,6 +457,23 @@ class Transaction:
443
457
  if sql:
444
458
  self._execute(sql, vals, cursor=cursor)
445
459
 
460
+ def mark_external_side_effect(self):
461
+ """Record that this transaction performed an irreversible external side
462
+ effect (a payment charge/capture, an email send, an external API write).
463
+
464
+ Once set, :meth:`Engine.exec_function` will refuse to auto-retry the
465
+ decorated function on transient DB errors and will re-raise instead — a
466
+ retry would re-run the side effect (e.g. double-charge a customer). Call
467
+ this immediately after the external call succeeds (R21).
468
+ """
469
+ self._external_side_effect = True
470
+ return self
471
+
472
+ @property
473
+ def has_external_side_effect(self):
474
+ """True if :meth:`mark_external_side_effect` was called on this txn."""
475
+ return self._external_side_effect
476
+
446
477
  def advisory_lock(self, key):
447
478
  """Acquire a transaction-scoped PostgreSQL advisory lock.
448
479