velocity-python 0.1.72__tar.gz → 0.1.73__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 (212) hide show
  1. {velocity_python-0.1.72 → velocity_python-0.1.73}/PKG-INFO +1 -1
  2. {velocity_python-0.1.72 → velocity_python-0.1.73}/pyproject.toml +1 -1
  3. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/async_support.py +50 -24
  5. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/engine.py +71 -6
  6. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/result.py +27 -0
  7. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/transaction.py +194 -19
  8. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/PKG-INFO +1 -1
  9. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  10. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_async_support.py +41 -0
  11. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_connection_resilience.py +2 -2
  12. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_retry_side_effect_guard.py +3 -3
  13. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_single_autocommit_safety.py +1 -0
  14. velocity_python-0.1.73/tests/test_transaction_commit_and_ownership.py +240 -0
  15. velocity_python-0.1.73/tests/test_transaction_edge_cases.py +225 -0
  16. {velocity_python-0.1.72 → velocity_python-0.1.73}/LICENSE +0 -0
  17. {velocity_python-0.1.72 → velocity_python-0.1.73}/README.md +0 -0
  18. {velocity_python-0.1.72 → velocity_python-0.1.73}/setup.cfg +0 -0
  19. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/__init__.py +0 -0
  20. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/amplify.py +0 -0
  21. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/amplify_build.py +0 -0
  22. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/__init__.py +0 -0
  23. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/backfill.py +0 -0
  24. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/indexing.py +0 -0
  25. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/references.py +0 -0
  26. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/service.py +0 -0
  27. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/assets/usage_index.py +0 -0
  28. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/dirty_pipeline.py +0 -0
  29. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/__init__.py +0 -0
  30. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/base_handler.py +0 -0
  31. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/context.py +0 -0
  32. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/context_factory.py +0 -0
  33. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/exceptions.py +0 -0
  34. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  35. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/masquerade.py +0 -0
  36. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  37. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  38. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  39. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/perf.py +0 -0
  40. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/response.py +0 -0
  41. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  42. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/s3.py +0 -0
  43. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/ssm_config.py +0 -0
  44. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/tests/__init__.py +0 -0
  45. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  46. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  47. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/aws/tests/test_response.py +0 -0
  48. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/__init__.py +0 -0
  49. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/__init__.py +0 -0
  50. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/column.py +0 -0
  51. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/database.py +0 -0
  52. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/decorators.py +0 -0
  53. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/jsonproxy.py +0 -0
  54. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/row.py +0 -0
  55. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/sequence.py +0 -0
  56. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/table.py +0 -0
  57. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/core/view.py +0 -0
  58. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/exceptions.py +0 -0
  59. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/migrations.py +0 -0
  60. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/__init__.py +0 -0
  61. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/base/__init__.py +0 -0
  62. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/base/initializer.py +0 -0
  63. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/base/operators.py +0 -0
  64. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/base/sql.py +0 -0
  65. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/base/types.py +0 -0
  66. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/mysql/__init__.py +0 -0
  67. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/mysql/operators.py +0 -0
  68. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/mysql/reserved.py +0 -0
  69. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/mysql/sql.py +0 -0
  70. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/mysql/types.py +0 -0
  71. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/postgres/__init__.py +0 -0
  72. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/postgres/operators.py +0 -0
  73. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/postgres/reserved.py +0 -0
  74. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/postgres/sql.py +0 -0
  75. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/postgres/types.py +0 -0
  76. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  77. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlite/operators.py +0 -0
  78. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  79. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlite/sql.py +0 -0
  80. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlite/types.py +0 -0
  81. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  82. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  83. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  84. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  85. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/sqlserver/types.py +0 -0
  86. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/servers/tablehelper.py +0 -0
  87. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/__init__.py +0 -0
  88. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/common_db_test.py +0 -0
  89. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/__init__.py +0 -0
  90. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/common.py +0 -0
  91. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/conftest.py +0 -0
  92. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_column.py +0 -0
  93. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  94. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_database.py +0 -0
  95. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  96. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  97. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  98. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_result.py +0 -0
  99. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_row.py +0 -0
  100. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  101. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  102. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  103. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  104. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  105. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_table.py +0 -0
  106. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  107. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  108. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/sql/__init__.py +0 -0
  109. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/sql/common.py +0 -0
  110. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  111. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  112. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  113. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_db_utils.py +0 -0
  114. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_postgres.py +0 -0
  115. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  116. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  117. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_result_caching.py +0 -0
  118. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  119. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  120. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  121. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  122. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_sql_builder.py +0 -0
  123. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_tablehelper.py +0 -0
  124. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/tests/test_view_helper.py +0 -0
  125. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/db/utils.py +0 -0
  126. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/logging.py +0 -0
  127. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/__init__.py +0 -0
  128. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/conv/__init__.py +0 -0
  129. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/conv/iconv.py +0 -0
  130. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/conv/oconv.py +0 -0
  131. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/db.py +0 -0
  132. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/export.py +0 -0
  133. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/format.py +0 -0
  134. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/mail.py +0 -0
  135. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/merge.py +0 -0
  136. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/pdf.py +0 -0
  137. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/__init__.py +0 -0
  138. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_db.py +0 -0
  139. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_fix.py +0 -0
  140. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_format.py +0 -0
  141. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_iconv.py +0 -0
  142. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_merge.py +0 -0
  143. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_oconv.py +0 -0
  144. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_original_error.py +0 -0
  145. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tests/test_timer.py +0 -0
  146. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/timer.py +0 -0
  147. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/misc/tools.py +0 -0
  148. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/__init__.py +0 -0
  149. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/authorizenet_adapter.py +0 -0
  150. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/authorizenet_mirror.py +0 -0
  151. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/base_adapter.py +0 -0
  152. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/braintree_adapter.py +0 -0
  153. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/braintree_mirror.py +0 -0
  154. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/charge_rules.py +0 -0
  155. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/stripe_adapter.py +0 -0
  156. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity/payment/stripe_mirror.py +0 -0
  157. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  158. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/entry_points.txt +0 -0
  159. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/requires.txt +0 -0
  160. {velocity_python-0.1.72 → velocity_python-0.1.73}/src/velocity_python.egg-info/top_level.txt +0 -0
  161. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_amplify_build.py +0 -0
  162. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_asset_indexing.py +0 -0
  163. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_asset_references.py +0 -0
  164. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_assets_service.py +0 -0
  165. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_batch_operations.py +0 -0
  166. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_column_tx_arg.py +0 -0
  167. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_concurrency_safety.py +0 -0
  168. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_connection_pool.py +0 -0
  169. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_context_job_descriptions.py +0 -0
  170. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_db_credentials_ssm_cascade.py +0 -0
  171. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_decorators.py +0 -0
  172. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_dirty_pipeline_fast_path.py +0 -0
  173. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_email_processing.py +0 -0
  174. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_get_cognito_user_provider.py +0 -0
  175. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_http_handler_rollback.py +0 -0
  176. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_iconv_money_to_cents.py +0 -0
  177. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_identifier_injection_guard.py +0 -0
  178. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_json_columns.py +0 -0
  179. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_jsonb_dict_adapter.py +0 -0
  180. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_lambda_handler.py +0 -0
  181. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_lambda_handler_auth.py +0 -0
  182. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_lambda_handler_masquerade.py +0 -0
  183. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_masquerade_grant.py +0 -0
  184. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_mixins_import.py +0 -0
  185. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_n_plus_one.py +0 -0
  186. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_observability.py +0 -0
  187. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_payment_authorizenet_adapter.py +0 -0
  188. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_payment_braintree_adapter.py +0 -0
  189. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_payment_braintree_mirror.py +0 -0
  190. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_payment_profile_sorting.py +0 -0
  191. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_payment_stripe_adapter.py +0 -0
  192. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_pdf.py +0 -0
  193. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_prepared_statements.py +0 -0
  194. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_psycopg3_upgrade.py +0 -0
  195. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_query_cache.py +0 -0
  196. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_restricted_direct_tables.py +0 -0
  197. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_return_default_safety.py +0 -0
  198. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_row_batch_update.py +0 -0
  199. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_row_cache_staleness.py +0 -0
  200. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_schema_migrations.py +0 -0
  201. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_security_hardening.py +0 -0
  202. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_server_cursor.py +0 -0
  203. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_spreadsheet_functions.py +0 -0
  204. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_sqlite_backend.py +0 -0
  205. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_sqs_per_record_transactions.py +0 -0
  206. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_ssm_config.py +0 -0
  207. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_store_user_data.py +0 -0
  208. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  209. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_table_alter.py +0 -0
  210. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_transaction_class_wrapping.py +0 -0
  211. {velocity_python-0.1.72 → velocity_python-0.1.73}/tests/test_where_clause_validation.py +0 -0
  212. {velocity_python-0.1.72 → velocity_python-0.1.73}/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.72
3
+ Version: 0.1.73
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.72"
7
+ version = "0.1.73"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.72"
1
+ __version__ = version = "0.1.73"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  import asyncio
29
29
  import logging
30
30
  import os
31
+ import re
31
32
  import time as _time
32
33
  from collections import OrderedDict
33
34
  from collections.abc import Mapping
@@ -426,6 +427,16 @@ class AsyncTransaction:
426
427
  async def __aexit__(self, exc_type, exc_val, exc_tb):
427
428
  if exc_type:
428
429
  await self.rollback()
430
+ # Discard the connection on the error path WITHOUT committing.
431
+ # (The previous code fell through to close(), which committed even
432
+ # after a rollback.)
433
+ if self.connection:
434
+ try:
435
+ await self.connection.close()
436
+ except Exception:
437
+ pass
438
+ self.connection = None
439
+ return # do not suppress the original exception
429
440
  await self.close()
430
441
 
431
442
  async def _ensure_connection(self):
@@ -507,21 +518,23 @@ class AsyncTransaction:
507
518
  stack_info=True,
508
519
  )
509
520
 
510
- # R14 — N+1 detection
511
- if _N_PLUS_1_THRESHOLD:
512
- from velocity.db.core.transaction import _classify_sql, _extract_table_name
513
- op = _classify_sql(sql)
514
- if op == "SELECT":
515
- tbl = _extract_table_name(sql)
516
- if tbl:
517
- self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
518
- count = self._table_select_counts[tbl]
519
- if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
520
- self._n1_warned.add(tbl)
521
- _logger.warning(
522
- "Possible N+1: table %s queried %d times in async transaction",
523
- tbl, count,
524
- )
521
+ # R14 — N+1 detection / R33 — raw-write cache invalidation
522
+ from velocity.db.core.transaction import _classify_sql, _extract_table_name
523
+ op = _classify_sql(sql)
524
+ if _N_PLUS_1_THRESHOLD and op == "SELECT":
525
+ tbl = _extract_table_name(sql)
526
+ if tbl:
527
+ self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
528
+ count = self._table_select_counts[tbl]
529
+ if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
530
+ self._n1_warned.add(tbl)
531
+ _logger.warning(
532
+ "Possible N+1: table %s queried %d times in async transaction",
533
+ tbl, count,
534
+ )
535
+ # Keep the query cache correct for raw writes that bypass the table API.
536
+ if self._query_cache and op in ("INSERT", "UPDATE", "DELETE", "DDL"):
537
+ self.invalidate_cache(_extract_table_name(sql))
525
538
 
526
539
  result = AsyncResult(cursor, self, sql, parms)
527
540
  await result._init()
@@ -554,7 +567,13 @@ class AsyncTransaction:
554
567
  async def commit(self):
555
568
  """Commit the async transaction."""
556
569
  if self.connection:
557
- await self.connection.commit()
570
+ try:
571
+ await self.connection.commit()
572
+ except Exception as e:
573
+ # Classify commit-time failures (serialization failure, deadlock,
574
+ # deferred-constraint violation) like the sync path does, instead
575
+ # of letting a raw driver error escape.
576
+ raise self.engine.process_error(e)
558
577
  if self._query_count:
559
578
  _logger.debug(
560
579
  "Async transaction commit: %d queries in %.1f ms",
@@ -570,12 +589,17 @@ class AsyncTransaction:
570
589
  pass
571
590
 
572
591
  async def close(self):
573
- """Commit (if needed) and close the async connection."""
574
- if self.connection:
575
- try:
576
- await self.commit()
577
- except Exception:
578
- pass
592
+ """Commit (if needed) and close the async connection.
593
+
594
+ A commit failure is **re-raised** (after the connection is closed)
595
+ rather than swallowed — otherwise a failed COMMIT would look successful
596
+ and the caller's writes would be silently lost.
597
+ """
598
+ if not self.connection:
599
+ return
600
+ try:
601
+ await self.commit()
602
+ finally:
579
603
  try:
580
604
  await self.connection.close()
581
605
  except Exception:
@@ -647,7 +671,9 @@ class AsyncTransaction:
647
671
  if table_name is None:
648
672
  self._query_cache.clear()
649
673
  return
650
- tbl_lower = table_name.lower()
651
- to_remove = [k for k in self._query_cache if tbl_lower in k[0].lower()]
674
+ # R33 — whole-word match (see sync Transaction.invalidate_cache): a
675
+ # substring match would wrongly evict "users" when invalidating "user".
676
+ pattern = re.compile(r"\b" + re.escape(table_name.lower()) + r"\b")
677
+ to_remove = [k for k in self._query_cache if pattern.search(k[0].lower())]
652
678
  for k in to_remove:
653
679
  del self._query_cache[k]
@@ -61,6 +61,27 @@ def _build_error_code_map(sql_dialect) -> dict:
61
61
  _ENGINE_FILE = os.path.normpath(__file__)
62
62
 
63
63
 
64
+ # R34 — Optional wall-clock ceiling on the retry envelope. The count caps
65
+ # (MAX_RETRIES=100 for deadlock/serialization) combined with up to 2s of backoff
66
+ # per attempt can spin for minutes on a *deterministic* conflict, burning a whole
67
+ # Lambda/request timeout. A budget lets a caller bound total retry wall time.
68
+ # 0 (default) = disabled, preserving the historical count-only behaviour.
69
+ _RETRY_BUDGET_SECONDS = float(os.environ.get("VELOCITY_RETRY_BUDGET_SECONDS", "0"))
70
+
71
+
72
+ def _retry_budget_exhausted(loop_start):
73
+ """R34 — True (and log) once the cumulative retry wall time exceeds the
74
+ configured budget, so the envelope re-raises instead of retrying forever."""
75
+ if _RETRY_BUDGET_SECONDS and (time.perf_counter() - loop_start) > _RETRY_BUDGET_SECONDS:
76
+ logger.error(
77
+ "Retry wall-clock budget of %.1fs exhausted; re-raising instead of "
78
+ "continuing to retry (VELOCITY_RETRY_BUDGET_SECONDS).",
79
+ _RETRY_BUDGET_SECONDS,
80
+ )
81
+ return True
82
+ return False
83
+
84
+
64
85
  def _retry_blocked_by_side_effect(tx):
65
86
  """R21 — return True (and log) when *tx* has performed an irreversible
66
87
  external side effect, so the auto-retry envelope must re-raise instead of
@@ -530,12 +551,18 @@ class Engine:
530
551
  tx = args[pos]
531
552
 
532
553
  if tx:
554
+ # Joined a caller-owned transaction: the caller drives
555
+ # commit/rollback/retry. _owns_tx stays False.
533
556
  return self.exec_function(func_or_cls, tx, *args, **kwds)
534
557
 
535
558
  with Transaction(self) as local_tx:
536
559
  pos = names.index("tx")
537
560
  new_args = args[:pos] + (local_tx,) + args[pos:]
538
- return self.exec_function(func_or_cls, local_tx, *new_args, **kwds)
561
+ # We created this transaction, so we own it: commit it and
562
+ # act as the retry boundary.
563
+ return self.exec_function(
564
+ func_or_cls, local_tx, *new_args, _owns_tx=True, **kwds
565
+ )
539
566
 
540
567
  return new_function
541
568
 
@@ -579,28 +606,62 @@ class Engine:
579
606
 
580
607
  return Transaction(self)
581
608
 
582
- def exec_function(self, function, _tx, *args, **kwds):
609
+ def exec_function(self, function, _tx, *args, _owns_tx=False, **kwds):
583
610
  """
584
611
  Executes the given function inside the transaction `_tx`.
585
- Retries if it raises DbRetryTransaction or DbLockTimeoutError, up to MAX_RETRIES times.
612
+
613
+ Retry / commit semantics:
614
+
615
+ * When this invocation **owns** the transaction (``_owns_tx=True`` — the
616
+ decorator created it for an omitted-``tx`` call) and is the outermost
617
+ such invocation (``depth == 0``), it is the *retry boundary*. It
618
+ commits the transaction **inside** the retry envelope, so commit-time
619
+ serialization failures / deadlocks / deferred-constraint violations
620
+ are classified and retried too — not just statement-time errors. It
621
+ retries on ``DbRetryTransaction`` / ``DbLockTimeoutError`` (up to
622
+ ``MAX_RETRIES``) and on transient ``DbConnectionError`` (up to 6).
623
+
624
+ * When the transaction was **passed in** (``_owns_tx=False``), the caller
625
+ owns it. This invocation must NOT commit, roll back, or retry — a
626
+ rollback would silently discard the caller's other uncommitted work on
627
+ the same transaction, and a retry would re-run only this function
628
+ against state the caller no longer has. It simply runs the function;
629
+ the owner drives commit/rollback/retry. Nested calls (``depth > 0``)
630
+ are likewise pass-through.
631
+
632
+ Note: a manually created ``with engine.transaction() as tx:`` that is
633
+ passed to a decorated function therefore gets no auto-retry — that is
634
+ intentional. Wrap the outer scope in ``@engine.transaction`` (so the
635
+ framework owns the transaction) if you want commit-and-retry semantics.
586
636
  """
587
637
  depth = getattr(_tx, "_exec_function_depth", 0)
588
638
  setattr(_tx, "_exec_function_depth", depth + 1)
589
639
 
590
640
  try:
591
- if depth > 0:
592
- # Not top-level. Just call the function.
641
+ if not (_owns_tx and depth == 0):
642
+ # Joined a caller-owned transaction, or a nested call. Just run
643
+ # the function; the transaction's owner handles commit, rollback
644
+ # and retry. Retrying here would roll back work we don't own.
593
645
  return function(*args, **kwds)
594
646
  else:
595
647
  retry_count = 0
596
648
  lock_timeout_count = 0
597
649
  connection_retry_count = 0
650
+ loop_start = time.perf_counter()
598
651
  while True:
599
652
  try:
600
- return function(*args, **kwds)
653
+ result = function(*args, **kwds)
654
+ # Commit INSIDE the retry envelope so a commit-time
655
+ # serialization failure / deadlock / deferred-constraint
656
+ # violation is classified and retried, instead of
657
+ # escaping as a raw driver error after the loop exits.
658
+ _tx.commit()
659
+ return result
601
660
  except exceptions.DbRetryTransaction:
602
661
  if _retry_blocked_by_side_effect(_tx):
603
662
  raise
663
+ if _retry_budget_exhausted(loop_start):
664
+ raise
604
665
  retry_count += 1
605
666
  if retry_count > self.MAX_RETRIES:
606
667
  raise
@@ -610,6 +671,8 @@ class Engine:
610
671
  except exceptions.DbLockTimeoutError:
611
672
  if _retry_blocked_by_side_effect(_tx):
612
673
  raise
674
+ if _retry_budget_exhausted(loop_start):
675
+ raise
613
676
  lock_timeout_count += 1
614
677
  if lock_timeout_count > self.MAX_RETRIES:
615
678
  raise
@@ -624,6 +687,8 @@ class Engine:
624
687
  # double-charge a customer.
625
688
  if _retry_blocked_by_side_effect(_tx):
626
689
  raise
690
+ if _retry_budget_exhausted(loop_start):
691
+ raise
627
692
  msg = str(e).strip().lower()
628
693
  if not getattr(
629
694
  self.sql, "is_transient_connection_error_message", lambda _m: False
@@ -1,5 +1,6 @@
1
1
  from velocity.misc.format import to_json
2
2
  from velocity.db.core.row import Row
3
+ from velocity.db import exceptions
3
4
 
4
5
 
5
6
  class Result:
@@ -52,6 +53,26 @@ class Result:
52
53
  # Pre-fetch the first row to enable immediate boolean evaluation
53
54
  self._fetch_first_row()
54
55
 
56
+ def _outlived_transaction(self):
57
+ """True when a fetch failed because this Result outlived its transaction.
58
+
59
+ Once the owning transaction commits/closes it releases its connection
60
+ (``tx.connection`` becomes ``None``) — and, when pooled, that connection
61
+ may already be serving another transaction. Continuing to read this
62
+ Result's cursor then returns nothing (or another transaction's data).
63
+ Silently reporting "no more rows" in that case hides real data, so the
64
+ caller is told to materialize inside the transaction instead.
65
+ """
66
+ tx = self.__tx
67
+ return tx is not None and getattr(tx, "connection", None) is None
68
+
69
+ _OUTLIVED_TX_MESSAGE = (
70
+ "Result was iterated after its transaction was committed/closed — the "
71
+ "rows are no longer available (and a pooled connection may now belong to "
72
+ "another transaction). Materialize the result inside the transaction "
73
+ "(.all()/.one()/.scalar()/list(...)) before returning it."
74
+ )
75
+
55
76
  def _fetch_first_row(self):
56
77
  """
57
78
  Pre-fetch the first row from the cursor to enable immediate boolean evaluation.
@@ -138,6 +159,8 @@ class Result:
138
159
  self._cursor = None
139
160
  if isinstance(e, StopIteration):
140
161
  raise
162
+ if self._outlived_transaction():
163
+ raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
141
164
  raise StopIteration
142
165
  else:
143
166
  raise StopIteration
@@ -168,6 +191,8 @@ class Result:
168
191
  # If cursor is closed or has error, mark as exhausted
169
192
  self._exhausted = True
170
193
  self._cursor = None
194
+ if self._outlived_transaction():
195
+ raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
171
196
 
172
197
  def batch(self, qty=1):
173
198
  """
@@ -355,6 +380,8 @@ class Result:
355
380
  # If cursor error, return default
356
381
  self._exhausted = True
357
382
  self._cursor = None
383
+ if self._outlived_transaction():
384
+ raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
358
385
  return default
359
386
  return default
360
387
 
@@ -1,10 +1,13 @@
1
1
  import logging
2
2
  import os
3
+ import re
3
4
  import time as _time
4
5
  import traceback
5
6
  import uuid
6
7
  from collections import OrderedDict
8
+ from contextlib import contextmanager
7
9
 
10
+ from velocity.db import exceptions
8
11
  from velocity.db.core.row import Row
9
12
  from velocity.db.core.table import Table
10
13
  from velocity.db.core.view import View
@@ -185,6 +188,12 @@ class Transaction:
185
188
  # auto-retry envelope must NOT re-run a function after this, or a
186
189
  # transient DB error post-charge would charge the customer again.
187
190
  self._external_side_effect = False
191
+ # R31 — True once a statement has executed on the current connection and
192
+ # has not yet been committed/rolled back. Used to detect a connection
193
+ # that died *mid-transaction*: silently reconnecting there would start a
194
+ # fresh transaction and lose the prior uncommitted statements without
195
+ # any error. Reset on connect/commit/rollback.
196
+ self._in_progress = False
188
197
 
189
198
  def __str__(self):
190
199
  config = mask_config_for_display(self.engine.config)
@@ -204,15 +213,54 @@ class Transaction:
204
213
  if debug:
205
214
  print("Transaction.__exit__ - an exception occurred.")
206
215
  traceback.print_exc()
207
- self.rollback()
208
- # After an error + rollback, discard the connection from the pool
209
- # rather than returning it in a potentially dirty state.
210
- if self.__pooled and self.connection:
211
- self.engine.return_pooled_connection(self.connection, discard=True)
216
+ # Roll back, but never let a secondary failure here (e.g. rolling
217
+ # back an already-dead connection) mask the original exception that
218
+ # triggered the unwind.
219
+ try:
220
+ self.rollback()
221
+ except Exception:
222
+ pass
223
+ # After an error, discard the connection rather than returning or
224
+ # reusing it in a potentially dirty state. Release the pool slot
225
+ # in either mode so an error can't leak it.
226
+ if self.connection:
227
+ if self.__pooled:
228
+ self.engine.return_pooled_connection(self.connection, discard=True)
229
+ else:
230
+ try:
231
+ self.connection.close()
232
+ except Exception:
233
+ pass
212
234
  self.connection = None
213
235
  self.__pooled = False
236
+ return # do not suppress the exception
214
237
  self.close()
215
238
 
239
+ def _discard_dead_connection(self):
240
+ """Drop the current connection because it's dead/unusable.
241
+
242
+ If it was a pooled connection, return it to the pool with ``discard=True``
243
+ so the pool **releases its semaphore slot** (and decrements its count) —
244
+ otherwise a connection that died while checked out leaks a slot forever,
245
+ and enough such leaks exhaust the pool. Non-pooled connections are just
246
+ closed. Always clears ``_in_progress`` since any open work is gone.
247
+ """
248
+ dead = self.connection
249
+ self.connection = None
250
+ self._in_progress = False
251
+ if dead is None:
252
+ return
253
+ if self.__pooled:
254
+ try:
255
+ self.engine.return_pooled_connection(dead, discard=True)
256
+ except Exception:
257
+ pass
258
+ else:
259
+ try:
260
+ dead.close()
261
+ except Exception:
262
+ pass
263
+
216
264
  def cursor(self):
217
265
  """
218
266
  Retrieves a database cursor, opening a connection if necessary.
@@ -221,14 +269,32 @@ class Transaction:
221
269
  # (psycopg v3 uses `connection.closed` returning bool; v2 used int != 0.)
222
270
  if self.connection is not None:
223
271
  try:
224
- if getattr(self.connection, "closed", 0):
225
- self.connection = None
272
+ is_closed = bool(getattr(self.connection, "closed", 0))
226
273
  except Exception:
227
274
  # If the driver object is in a bad state, force a reconnect.
228
- self.connection = None
275
+ is_closed = True
276
+ if is_closed:
277
+ # R31 — If statements already ran in this transaction, silently
278
+ # reconnecting would open a NEW connection (a fresh transaction)
279
+ # and discard that uncommitted work with no error. Surface it as
280
+ # a transient connection error instead, so the @engine.transaction
281
+ # retry envelope re-runs the whole unit of work from the start
282
+ # rather than committing a partial/empty transaction.
283
+ in_progress = self._in_progress
284
+ # Discard the dead connection properly (releases the pool slot;
285
+ # the previous code nulled it and leaked the slot).
286
+ self._discard_dead_connection()
287
+ if in_progress:
288
+ raise exceptions.DbConnectionError(
289
+ "Database connection already closed mid-transaction; "
290
+ "uncommitted work in this transaction was lost and cannot "
291
+ "be recovered in place. (A function wrapped by "
292
+ "@engine.transaction will be retried from the start.)"
293
+ )
229
294
 
230
295
  if not self.connection:
231
296
  self.connection = self.engine.connect()
297
+ self._in_progress = False
232
298
  if debug:
233
299
  print(f"*** {id(self)} --> transaction.cursor()")
234
300
  return self.connection.cursor()
@@ -239,15 +305,36 @@ class Transaction:
239
305
  If the connection was obtained from the pool, it is returned to
240
306
  the pool instead of being closed.
241
307
  """
242
- if self.connection:
308
+ if not self.connection:
309
+ return
310
+ if debug:
311
+ print(f"<<< {id(self)} close connection.")
312
+ try:
243
313
  self.commit()
244
- if debug:
245
- print(f"<<< {id(self)} close connection.")
246
- if self.__pooled and self.engine.return_pooled_connection(self.connection):
247
- self.connection = None
314
+ except Exception:
315
+ # A commit-time failure (serialization failure, deadlock, or a
316
+ # deferred-constraint violation — all of which surface at COMMIT,
317
+ # not at statement time) leaves the connection in an aborted state.
318
+ # Discard it rather than returning a dirty connection to the pool,
319
+ # and ALWAYS release the pool slot so a commit failure can't leak
320
+ # it. Repeated commit-time failures would otherwise exhaust the
321
+ # pool, turning a transient error into a hard outage. Then re-raise
322
+ # so the caller still sees the commit error.
323
+ if self.__pooled:
324
+ self.engine.return_pooled_connection(self.connection, discard=True)
248
325
  else:
249
- self.connection.close()
250
- self.connection = None
326
+ try:
327
+ self.connection.close()
328
+ except Exception:
329
+ pass
330
+ self.connection = None
331
+ self.__pooled = False
332
+ raise
333
+ if self.__pooled and self.engine.return_pooled_connection(self.connection):
334
+ self.connection = None
335
+ else:
336
+ self.connection.close()
337
+ self.connection = None
251
338
 
252
339
  def execute(self, sql, parms=None, single=False, cursor=None, prepare=None):
253
340
  return self._execute(sql, parms, single, cursor, prepare=prepare)
@@ -259,6 +346,18 @@ class Transaction:
259
346
  self.connection = self.engine.connect()
260
347
 
261
348
  if single:
349
+ # R32 — single=True needs autocommit mode, which can't have an open
350
+ # transaction, so it must commit first. If there is uncommitted work
351
+ # pending, that work is silently committed here — warn, because
352
+ # single=True is meant for standalone statements (e.g. CREATE
353
+ # DATABASE) on a clean transaction, not mid-unit-of-work.
354
+ if self._in_progress:
355
+ _logger.warning(
356
+ "execute(single=True) is committing work already pending in "
357
+ "this transaction before switching to autocommit. single=True "
358
+ "is for standalone statements on a clean transaction; running "
359
+ "it mid-transaction silently commits the prior statements."
360
+ )
262
361
  self.commit()
263
362
  self.connection.autocommit = True
264
363
 
@@ -309,6 +408,20 @@ class Transaction:
309
408
  elapsed_ms = (_time.perf_counter() - t0) * 1000
310
409
  self._query_count += 1
311
410
  self._query_time_ms += elapsed_ms
411
+ if not single:
412
+ # A statement is now pending in an open transaction (single=True
413
+ # runs in autocommit, so nothing is left pending there).
414
+ self._in_progress = True
415
+
416
+ # R33 — Keep the query cache correct for raw writes that bypass the
417
+ # Table API (which invalidates on its own). A write run directly via
418
+ # tx.execute() would otherwise leave stale cached reads for that
419
+ # table. Cheap when the cache is empty (the common case).
420
+ if self.__query_cache:
421
+ op = _classify_sql(sql)
422
+ if op in ("INSERT", "UPDATE", "DELETE", "DDL"):
423
+ tbl = _extract_table_name(sql)
424
+ self.invalidate_cache(tbl) # tbl=None clears the whole cache
312
425
 
313
426
  # R12 — Slow query logging.
314
427
  if _SLOW_QUERY_MS and elapsed_ms > _SLOW_QUERY_MS:
@@ -395,6 +508,8 @@ class Transaction:
395
508
  except Exception as e:
396
509
  raise self.engine.process_error(e, sql, args_list)
397
510
 
511
+ # Rows are now pending in an open transaction.
512
+ self._in_progress = True
398
513
  return total
399
514
 
400
515
  def server_execute(self, sql, parms=None):
@@ -429,7 +544,18 @@ class Transaction:
429
544
  if self.connection:
430
545
  if debug:
431
546
  print(f"{id(self)} --- connection commit.")
432
- self.connection.commit()
547
+ try:
548
+ self.connection.commit()
549
+ except Exception as e:
550
+ # Serialization failures (40001), deadlocks (40P01) and deferred
551
+ # constraint violations are reported at COMMIT, not at statement
552
+ # time. Route them through the engine's classifier so they become
553
+ # the right velocity exception (e.g. DbRetryTransaction) and can
554
+ # be retried by exec_function's envelope — instead of escaping as
555
+ # a raw driver error after the retry loop has already exited.
556
+ raise self.engine.process_error(e)
557
+ # Work is durably committed; the transaction is clean again.
558
+ self._in_progress = False
433
559
  if self._query_count:
434
560
  _logger.debug(
435
561
  "Transaction commit: %d queries in %.1f ms",
@@ -448,6 +574,8 @@ class Transaction:
448
574
  if debug:
449
575
  print(f"{id(self)} --- connection rollback.")
450
576
  self.connection.rollback()
577
+ # Transaction is clean again — no uncommitted work pending.
578
+ self._in_progress = False
451
579
 
452
580
  def create_savepoint(self, sp=None, cursor=None):
453
581
  """
@@ -476,6 +604,49 @@ class Transaction:
476
604
  if sql:
477
605
  self._execute(sql, vals, cursor=cursor)
478
606
 
607
+ @contextmanager
608
+ def savepoint(self, name=None):
609
+ """Context manager for a nested savepoint (attempt-and-recover).
610
+
611
+ On a clean exit the savepoint is released. If the body raises, the
612
+ transaction is rolled back **to the savepoint** — undoing only the work
613
+ inside the block — and the exception is re-raised, leaving the outer
614
+ transaction usable.
615
+
616
+ This is the safe way to try a fallible operation inside a transaction on
617
+ PostgreSQL. Without a savepoint, the first error aborts the *whole*
618
+ transaction and every later statement fails with "current transaction is
619
+ aborted, commands ignored until end of transaction block"; a plain
620
+ ``try/except`` around a bare ``execute`` does **not** recover from that.
621
+ ::
622
+
623
+ with engine.transaction() as tx:
624
+ try:
625
+ with tx.savepoint():
626
+ tx.table("x").insert(maybe_dup)
627
+ except exceptions.DbDuplicateKeyError:
628
+ pass # tx is still usable here
629
+ tx.table("y").insert(...) # succeeds
630
+
631
+ Args:
632
+ name: Optional savepoint name; a random one is generated if omitted.
633
+ """
634
+ sp = self.create_savepoint(name)
635
+ try:
636
+ yield sp
637
+ except Exception:
638
+ # Roll back only the inner work and clear the aborted state so the
639
+ # outer transaction can continue. Guard cleanup so a dead connection
640
+ # can't mask the original error.
641
+ try:
642
+ self.rollback_savepoint(sp)
643
+ self.release_savepoint(sp)
644
+ except Exception:
645
+ pass
646
+ raise
647
+ else:
648
+ self.release_savepoint(sp)
649
+
479
650
  def mark_external_side_effect(self):
480
651
  """Record that this transaction performed an irreversible external side
481
652
  effect (a payment charge/capture, an email send, an external API write).
@@ -559,10 +730,14 @@ class Transaction:
559
730
  self._json_columns_cache.clear()
560
731
  return
561
732
  self._json_columns_cache.pop(table_name, None)
562
- # Simple substring match on the SQL text (first element of the key).
563
- tbl_lower = table_name.lower()
733
+ # R33 Whole-word match on the SQL text (first element of the key), not
734
+ # a substring: a substring match would wrongly evict "users" when asked
735
+ # to invalidate "user" (and vice-versa). \b boundaries treat quotes /
736
+ # dots / whitespace around an identifier as delimiters while keeping
737
+ # "order" from matching "order_items" (underscore is a word char).
738
+ pattern = re.compile(r"\b" + re.escape(table_name.lower()) + r"\b")
564
739
  to_remove = [
565
- k for k in self.__query_cache if tbl_lower in k[0].lower()
740
+ k for k in self.__query_cache if pattern.search(k[0].lower())
566
741
  ]
567
742
  for k in to_remove:
568
743
  del self.__query_cache[k]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.72
3
+ Version: 0.1.73
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
@@ -204,5 +204,7 @@ tests/test_store_user_data.py
204
204
  tests/test_sys_modified_count_postgres_demo.py
205
205
  tests/test_table_alter.py
206
206
  tests/test_transaction_class_wrapping.py
207
+ tests/test_transaction_commit_and_ownership.py
208
+ tests/test_transaction_edge_cases.py
207
209
  tests/test_where_clause_validation.py
208
210
  tests/test_write_hook_create_flow.py