velocity-python 0.1.75__tar.gz → 0.1.77__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 (215) hide show
  1. {velocity_python-0.1.75 → velocity_python-0.1.77}/PKG-INFO +1 -1
  2. {velocity_python-0.1.75 → velocity_python-0.1.77}/pyproject.toml +1 -1
  3. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/__init__.py +1 -1
  4. velocity_python-0.1.77/src/velocity/db/maintenance.py +329 -0
  5. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/PKG-INFO +1 -1
  6. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  7. velocity_python-0.1.77/tests/test_db_maintenance.py +198 -0
  8. {velocity_python-0.1.75 → velocity_python-0.1.77}/LICENSE +0 -0
  9. {velocity_python-0.1.75 → velocity_python-0.1.77}/README.md +0 -0
  10. {velocity_python-0.1.75 → velocity_python-0.1.77}/setup.cfg +0 -0
  11. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/__init__.py +0 -0
  12. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/amplify.py +0 -0
  13. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/amplify_build.py +0 -0
  14. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/__init__.py +0 -0
  15. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/backfill.py +0 -0
  16. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/indexing.py +0 -0
  17. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/references.py +0 -0
  18. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/service.py +0 -0
  19. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/assets/usage_index.py +0 -0
  20. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/dirty_pipeline.py +0 -0
  21. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/__init__.py +0 -0
  22. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/base_handler.py +0 -0
  23. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/context.py +0 -0
  24. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/context_factory.py +0 -0
  25. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/exceptions.py +0 -0
  26. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  27. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/masquerade.py +0 -0
  28. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  29. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  30. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  31. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/perf.py +0 -0
  32. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/response.py +0 -0
  33. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  34. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/s3.py +0 -0
  35. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/ssm_config.py +0 -0
  36. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/tests/__init__.py +0 -0
  37. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  38. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  39. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/aws/tests/test_response.py +0 -0
  40. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/__init__.py +0 -0
  41. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/__init__.py +0 -0
  42. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/async_support.py +0 -0
  43. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/column.py +0 -0
  44. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/database.py +0 -0
  45. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/decorators.py +0 -0
  46. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/engine.py +0 -0
  47. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/jsonproxy.py +0 -0
  48. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/result.py +0 -0
  49. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/row.py +0 -0
  50. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/sequence.py +0 -0
  51. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/table.py +0 -0
  52. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/transaction.py +0 -0
  53. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/core/view.py +0 -0
  54. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/exceptions.py +0 -0
  55. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/migrations.py +0 -0
  56. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/__init__.py +0 -0
  57. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/base/__init__.py +0 -0
  58. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/base/initializer.py +0 -0
  59. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/base/operators.py +0 -0
  60. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/base/sql.py +0 -0
  61. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/base/types.py +0 -0
  62. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/mysql/__init__.py +0 -0
  63. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/mysql/operators.py +0 -0
  64. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/mysql/reserved.py +0 -0
  65. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/mysql/sql.py +0 -0
  66. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/mysql/types.py +0 -0
  67. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/postgres/__init__.py +0 -0
  68. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/postgres/operators.py +0 -0
  69. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/postgres/reserved.py +0 -0
  70. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/postgres/sql.py +0 -0
  71. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/postgres/types.py +0 -0
  72. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  73. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlite/operators.py +0 -0
  74. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  75. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlite/sql.py +0 -0
  76. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlite/types.py +0 -0
  77. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  78. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  79. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  80. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  81. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/sqlserver/types.py +0 -0
  82. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/servers/tablehelper.py +0 -0
  83. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/__init__.py +0 -0
  84. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/common_db_test.py +0 -0
  85. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/__init__.py +0 -0
  86. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/common.py +0 -0
  87. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/conftest.py +0 -0
  88. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_column.py +0 -0
  89. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  90. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_database.py +0 -0
  91. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  92. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  93. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  94. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_result.py +0 -0
  95. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_row.py +0 -0
  96. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  97. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  98. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  99. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  100. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  101. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_table.py +0 -0
  102. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  103. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  104. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/sql/__init__.py +0 -0
  105. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/sql/common.py +0 -0
  106. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  107. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  108. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  109. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_db_utils.py +0 -0
  110. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_postgres.py +0 -0
  111. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  112. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  113. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_result_caching.py +0 -0
  114. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  115. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  116. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  117. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  118. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_sql_builder.py +0 -0
  119. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_tablehelper.py +0 -0
  120. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/tests/test_view_helper.py +0 -0
  121. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/db/utils.py +0 -0
  122. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/logging.py +0 -0
  123. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/__init__.py +0 -0
  124. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/conv/__init__.py +0 -0
  125. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/conv/iconv.py +0 -0
  126. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/conv/oconv.py +0 -0
  127. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/db.py +0 -0
  128. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/export.py +0 -0
  129. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/format.py +0 -0
  130. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/mail.py +0 -0
  131. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/merge.py +0 -0
  132. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/pdf.py +0 -0
  133. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/__init__.py +0 -0
  134. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_db.py +0 -0
  135. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_fix.py +0 -0
  136. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_format.py +0 -0
  137. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_iconv.py +0 -0
  138. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_merge.py +0 -0
  139. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_oconv.py +0 -0
  140. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_original_error.py +0 -0
  141. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tests/test_timer.py +0 -0
  142. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/timer.py +0 -0
  143. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/misc/tools.py +0 -0
  144. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/__init__.py +0 -0
  145. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/authorizenet_adapter.py +0 -0
  146. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/authorizenet_mirror.py +0 -0
  147. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/base_adapter.py +0 -0
  148. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/braintree_adapter.py +0 -0
  149. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/braintree_mirror.py +0 -0
  150. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/charge_rules.py +0 -0
  151. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/stripe_adapter.py +0 -0
  152. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity/payment/stripe_mirror.py +0 -0
  153. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  154. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/entry_points.txt +0 -0
  155. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/requires.txt +0 -0
  156. {velocity_python-0.1.75 → velocity_python-0.1.77}/src/velocity_python.egg-info/top_level.txt +0 -0
  157. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_amplify_build.py +0 -0
  158. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_asset_indexing.py +0 -0
  159. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_asset_references.py +0 -0
  160. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_assets_service.py +0 -0
  161. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_async_support.py +0 -0
  162. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_batch_operations.py +0 -0
  163. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_column_tx_arg.py +0 -0
  164. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_concurrency_safety.py +0 -0
  165. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_connection_pool.py +0 -0
  166. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_connection_resilience.py +0 -0
  167. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_context_job_descriptions.py +0 -0
  168. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_db_credentials_ssm_cascade.py +0 -0
  169. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_decorators.py +0 -0
  170. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_dirty_pipeline_fast_path.py +0 -0
  171. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_email_processing.py +0 -0
  172. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_enqueue_send_failures.py +0 -0
  173. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_get_cognito_user_provider.py +0 -0
  174. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_http_handler_rollback.py +0 -0
  175. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_iconv_money_to_cents.py +0 -0
  176. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_identifier_injection_guard.py +0 -0
  177. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_json_columns.py +0 -0
  178. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_jsonb_dict_adapter.py +0 -0
  179. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_lambda_handler.py +0 -0
  180. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_lambda_handler_auth.py +0 -0
  181. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_lambda_handler_masquerade.py +0 -0
  182. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_masquerade_grant.py +0 -0
  183. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_mixins_import.py +0 -0
  184. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_n_plus_one.py +0 -0
  185. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_observability.py +0 -0
  186. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_payment_authorizenet_adapter.py +0 -0
  187. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_payment_braintree_adapter.py +0 -0
  188. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_payment_braintree_mirror.py +0 -0
  189. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_payment_profile_sorting.py +0 -0
  190. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_payment_stripe_adapter.py +0 -0
  191. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_pdf.py +0 -0
  192. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_prepared_statements.py +0 -0
  193. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_psycopg3_upgrade.py +0 -0
  194. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_query_cache.py +0 -0
  195. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_restricted_direct_tables.py +0 -0
  196. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_retry_side_effect_guard.py +0 -0
  197. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_return_default_safety.py +0 -0
  198. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_row_batch_update.py +0 -0
  199. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_row_cache_staleness.py +0 -0
  200. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_schema_migrations.py +0 -0
  201. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_security_hardening.py +0 -0
  202. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_server_cursor.py +0 -0
  203. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_single_autocommit_safety.py +0 -0
  204. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_spreadsheet_functions.py +0 -0
  205. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_sqlite_backend.py +0 -0
  206. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_sqs_per_record_transactions.py +0 -0
  207. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_ssm_config.py +0 -0
  208. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_store_user_data.py +0 -0
  209. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  210. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_table_alter.py +0 -0
  211. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_transaction_class_wrapping.py +0 -0
  212. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_transaction_commit_and_ownership.py +0 -0
  213. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_transaction_edge_cases.py +0 -0
  214. {velocity_python-0.1.75 → velocity_python-0.1.77}/tests/test_where_clause_validation.py +0 -0
  215. {velocity_python-0.1.75 → velocity_python-0.1.77}/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.75
3
+ Version: 0.1.77
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.75"
7
+ version = "0.1.77"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.75"
1
+ __version__ = version = "0.1.77"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -0,0 +1,329 @@
1
+ """PostgreSQL database maintenance toolkit.
2
+
3
+ Safe, efficient database-admin operations that velocity's normal transactional
4
+ model can't express. ``CREATE``/``DROP``/``RENAME DATABASE`` and template clones
5
+ must run in **autocommit** and must **not** run while connected to the source or
6
+ destination database. ``DatabaseMaintenance`` connects to a neutral *maintenance*
7
+ database (default ``postgres``) so the source/dest are never the active session,
8
+ runs DDL in autocommit, and terminates other sessions as needed.
9
+
10
+ from velocity.db import maintenance
11
+
12
+ m = maintenance.DatabaseMaintenance() # env (DBHost/DBUser/...), maint db 'postgres'
13
+
14
+ # Server-side template clone -- instant, no dump/restore round-trip:
15
+ m.refresh("caringcent-production", "caringcent-develop") # snapshot dest, then clone source -> dest
16
+ m.snapshot("caringcent-develop") # -> caringcent-develop-snapshot-<ts>
17
+ m.prune_snapshots("caringcent-develop", keep=3) # drop all but the 3 newest snapshots
18
+
19
+ Every database name is identifier-quoted; string-literal comparisons are escaped.
20
+ A configurable ``protected`` set blocks destructive ops (drop / rename / overwrite)
21
+ on critical databases (default: ``caringcent-production``).
22
+
23
+ Snapshot databases are named ``<db>-snapshot-<YYYYMMDD-HHMMSS>`` -- a **sortable**
24
+ timestamp, so listing/pruning by name is chronological.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import datetime
30
+ import os
31
+ from typing import Iterable, List, Optional
32
+
33
+ from velocity.db.servers import postgres
34
+
35
+ SNAPSHOT_INFIX = "-snapshot-"
36
+ TS_FORMAT = "%Y%m%d-%H%M%S" # sortable: lexical order == chronological order
37
+ DEFAULT_PROTECTED = frozenset({"caringcent-production"})
38
+
39
+ _BACKUP_FORMAT_FLAGS = {"custom": "-Fc", "plain": "-Fp", "directory": "-Fd", "tar": "-Ft"}
40
+
41
+
42
+ def quote_identifier(name: str) -> str:
43
+ """Quote a database identifier (doubles embedded double-quotes)."""
44
+ return '"' + str(name).replace('"', '""') + '"'
45
+
46
+
47
+ def quote_literal(value: str) -> str:
48
+ """Quote a string literal (doubles embedded single-quotes)."""
49
+ return "'" + str(value).replace("'", "''") + "'"
50
+
51
+
52
+ def timestamp() -> str:
53
+ return datetime.datetime.now().strftime(TS_FORMAT)
54
+
55
+
56
+ class DatabaseMaintenance:
57
+ """Connection-pooled, autocommit-aware PostgreSQL admin operations."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ maintenance_db: str = "postgres",
63
+ config: Optional[dict] = None,
64
+ protected: Iterable[str] = DEFAULT_PROTECTED,
65
+ engine=None,
66
+ ):
67
+ self.maintenance_db = maintenance_db
68
+ self.protected = frozenset(protected or ())
69
+ self._config = dict(config or {})
70
+ # Lazily initialized so construction never opens a connection (keeps the
71
+ # class unit-testable without a live database).
72
+ self._engine = engine
73
+
74
+ # ------------------------------------------------------------------ engine
75
+ def _engine_config(self, dbname: str) -> dict:
76
+ """Connection config that connects to ``dbname``.
77
+
78
+ The source/destination database is operated *on*, never connected *to*, so
79
+ any caller-supplied database name is dropped and replaced. (libpq uses
80
+ ``dbname``; ``postgres.initialize`` remaps a stray ``database`` key, which
81
+ would otherwise override the forced connection database.)
82
+ """
83
+ cfg = dict(self._config)
84
+ cfg.pop("database", None)
85
+ cfg.pop("dbname", None)
86
+ cfg["dbname"] = dbname
87
+ return cfg
88
+
89
+ def _get_engine(self):
90
+ if self._engine is None:
91
+ self._engine = postgres.initialize(config=self._engine_config(self.maintenance_db))
92
+ return self._engine
93
+
94
+ def _exec(self, *statements: str) -> None:
95
+ """Run DDL/admin statements in autocommit (required for CREATE/DROP/RENAME DATABASE)."""
96
+ engine = self._get_engine()
97
+
98
+ @engine.transaction
99
+ def _run(tx):
100
+ tx.rollback() # discard the implicit transaction opened on checkout
101
+ tx.connection.autocommit = True
102
+ for sql in statements:
103
+ if sql:
104
+ tx.execute(sql)
105
+
106
+ _run()
107
+
108
+ def _query(self, sql: str) -> List[dict]:
109
+ engine = self._get_engine()
110
+
111
+ @engine.transaction
112
+ def _run(tx):
113
+ return list(tx.execute(sql).as_dict())
114
+
115
+ return _run()
116
+
117
+ def _conn_params(self) -> dict:
118
+ return {
119
+ "host": self._config.get("host") or os.environ.get("DBHost"),
120
+ "port": self._config.get("port") or os.environ.get("DBPort"),
121
+ "user": self._config.get("user") or os.environ.get("DBUser"),
122
+ "password": self._config.get("password") or os.environ.get("DBPassword"),
123
+ }
124
+
125
+ # ------------------------------------------------------------------ guards
126
+ def _guard(self, db: str, action: str) -> None:
127
+ if db in self.protected:
128
+ raise PermissionError(
129
+ f"Refusing to {action} protected database {db!r}. "
130
+ "Construct with protected=... to override."
131
+ )
132
+
133
+ # ------------------------------------------------------------------ SQL helpers
134
+ @staticmethod
135
+ def _terminate_sql(db: str) -> str:
136
+ return (
137
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
138
+ f"WHERE datname = {quote_literal(db)} AND pid <> pg_backend_pid()"
139
+ )
140
+
141
+ # ------------------------------------------------------------------ queries
142
+ def exists(self, db: str) -> bool:
143
+ return bool(
144
+ self._query(f"SELECT 1 AS x FROM pg_database WHERE datname = {quote_literal(db)}")
145
+ )
146
+
147
+ def list_databases(self, *, pattern: Optional[str] = None) -> List[str]:
148
+ where = "WHERE datistemplate = false"
149
+ if pattern:
150
+ where += f" AND datname LIKE {quote_literal(pattern)}"
151
+ rows = self._query(f"SELECT datname FROM pg_database {where} ORDER BY datname")
152
+ return [r["datname"] for r in rows]
153
+
154
+ def list_snapshots(self, db: str) -> List[str]:
155
+ """Snapshot databases for ``db``, oldest first (sortable timestamp suffix)."""
156
+ return sorted(self.list_databases(pattern=f"{db}{SNAPSHOT_INFIX}%"))
157
+
158
+ # ------------------------------------------------------------------ connection control
159
+ def terminate_connections(self, db: str) -> None:
160
+ self._exec(self._terminate_sql(db))
161
+
162
+ # ------------------------------------------------------------------ core ops
163
+ def clone(self, source: str, dest: str, *, drop_existing: bool = False) -> str:
164
+ """``CREATE DATABASE dest WITH TEMPLATE source`` (server-side, near-instant)."""
165
+ self._guard(dest, "overwrite")
166
+ if self.exists(dest):
167
+ if not drop_existing:
168
+ raise ValueError(
169
+ f"Destination {dest!r} already exists. Pass drop_existing=True to replace it."
170
+ )
171
+ self.drop(dest)
172
+ # A template clone requires no other sessions on the source.
173
+ self._exec(
174
+ self._terminate_sql(source),
175
+ f"CREATE DATABASE {quote_identifier(dest)} WITH TEMPLATE {quote_identifier(source)}",
176
+ )
177
+ return dest
178
+
179
+ def snapshot(self, db: str, *, label: Optional[str] = None) -> str:
180
+ """Clone ``db`` to ``<db>-snapshot-<ts>`` (or a custom ``label``)."""
181
+ dest = f"{db}{SNAPSHOT_INFIX}{label or timestamp()}"
182
+ return self.clone(db, dest)
183
+
184
+ def rename(self, old: str, new: str) -> str:
185
+ self._guard(old, "rename")
186
+ self._exec(
187
+ self._terminate_sql(old),
188
+ f"ALTER DATABASE {quote_identifier(old)} RENAME TO {quote_identifier(new)}",
189
+ )
190
+ return new
191
+
192
+ def drop(self, db: str, *, if_exists: bool = True) -> None:
193
+ self._guard(db, "drop")
194
+ clause = "DROP DATABASE IF EXISTS" if if_exists else "DROP DATABASE"
195
+ self._exec(self._terminate_sql(db), f"{clause} {quote_identifier(db)}")
196
+
197
+ def refresh(self, source: str, dest: str, *, keep_snapshot: bool = True) -> str:
198
+ """Replace ``dest`` with a fresh template-clone of ``source``.
199
+
200
+ Safe sequence: terminate ``dest`` sessions -> rename ``dest`` to
201
+ ``dest-snapshot-<ts>`` (or drop it when ``keep_snapshot=False``) -> clone
202
+ ``source`` -> ``dest``. Keeping the snapshot makes the refresh reversible.
203
+ """
204
+ self._guard(dest, "overwrite")
205
+ if self.exists(dest):
206
+ if keep_snapshot:
207
+ self.rename(dest, f"{dest}{SNAPSHOT_INFIX}{timestamp()}")
208
+ else:
209
+ self.drop(dest)
210
+ return self.clone(source, dest)
211
+
212
+ def prune_snapshots(
213
+ self,
214
+ db: str,
215
+ *,
216
+ keep: int = 3,
217
+ older_than_days: Optional[int] = None,
218
+ dry_run: bool = False,
219
+ ) -> List[str]:
220
+ """Drop old snapshot databases for ``db``.
221
+
222
+ With ``older_than_days`` set, drops snapshots whose timestamp is older than
223
+ the cutoff. Otherwise keeps the ``keep`` newest and drops the rest. Returns
224
+ the names dropped (or that would be dropped, when ``dry_run=True``).
225
+ """
226
+ snaps = self.list_snapshots(db) # ascending (oldest first)
227
+ to_drop: List[str] = []
228
+ if older_than_days is not None:
229
+ cutoff = datetime.datetime.now() - datetime.timedelta(days=older_than_days)
230
+ prefix = f"{db}{SNAPSHOT_INFIX}"
231
+ for name in snaps:
232
+ suffix = name[len(prefix):]
233
+ try:
234
+ when = datetime.datetime.strptime(suffix, TS_FORMAT)
235
+ except ValueError:
236
+ continue # unrecognized suffix -> leave it alone
237
+ if when < cutoff:
238
+ to_drop.append(name)
239
+ elif keep >= 0 and len(snaps) > keep:
240
+ to_drop = snaps[: len(snaps) - keep]
241
+
242
+ if not dry_run:
243
+ for name in to_drop:
244
+ self.drop(name)
245
+ return to_drop
246
+
247
+ # ------------------------------------------------------------------ portable backup/restore
248
+ def backup_to_file(
249
+ self, db: str, path: str, *, fmt: str = "custom", extra_args: Iterable[str] = ()
250
+ ) -> str:
251
+ """Portable off-instance backup via ``pg_dump`` (use for cross-server moves;
252
+ prefer ``snapshot``/``clone`` for same-server copies). ``extra_args`` are
253
+ passed through to ``pg_dump`` (e.g. ``("-O",)`` for no-owner)."""
254
+ if fmt not in _BACKUP_FORMAT_FLAGS:
255
+ raise ValueError(f"Unknown fmt {fmt!r}; choose one of {sorted(_BACKUP_FORMAT_FLAGS)}.")
256
+ p = self._conn_params()
257
+ self._run_tool(
258
+ ["pg_dump", "-h", str(p["host"]), "-p", str(p["port"]), "-U", str(p["user"]),
259
+ _BACKUP_FORMAT_FLAGS[fmt], *extra_args, "-f", path, db]
260
+ )
261
+ return path
262
+
263
+ def restore_from_file(
264
+ self,
265
+ path: str,
266
+ dest: str,
267
+ *,
268
+ fmt: str = "custom",
269
+ drop_existing: bool = False,
270
+ extra_args: Iterable[str] = (),
271
+ ) -> str:
272
+ """Restore a ``pg_dump`` file into a freshly created ``dest`` database.
273
+ ``extra_args`` are passed to ``pg_restore``/``psql``."""
274
+ self._guard(dest, "overwrite")
275
+ if self.exists(dest):
276
+ if not drop_existing:
277
+ raise ValueError(
278
+ f"Destination {dest!r} already exists. Pass drop_existing=True to replace it."
279
+ )
280
+ self.drop(dest)
281
+ self._exec(f"CREATE DATABASE {quote_identifier(dest)}")
282
+ p = self._conn_params()
283
+ base = ["-h", str(p["host"]), "-p", str(p["port"]), "-U", str(p["user"])]
284
+ if fmt == "plain":
285
+ self._run_tool(["psql", *base, *extra_args, "-d", dest, "-f", path])
286
+ else:
287
+ self._run_tool(["pg_restore", *base, *extra_args, "-d", dest, path])
288
+ return dest
289
+
290
+ def _run_tool(self, cmd: List[str]) -> None:
291
+ import subprocess
292
+
293
+ env = dict(os.environ)
294
+ password = self._conn_params().get("password")
295
+ if password:
296
+ env["PGPASSWORD"] = str(password)
297
+ subprocess.run(cmd, check=True, env=env, capture_output=True, text=True)
298
+
299
+ # ------------------------------------------------------------------ maintenance
300
+ def vacuum(self, db: str, *, analyze: bool = True, full: bool = False) -> None:
301
+ """``VACUUM`` the target database (autocommit; cannot run in a transaction block)."""
302
+ opts = []
303
+ if full:
304
+ opts.append("FULL")
305
+ if analyze:
306
+ opts.append("ANALYZE")
307
+ stmt = "VACUUM" + (f" ({', '.join(opts)})" if opts else "")
308
+ self._exec_in(db, stmt)
309
+
310
+ def analyze(self, db: str) -> None:
311
+ self._exec_in(db, "ANALYZE")
312
+
313
+ def reindex(self, db: str) -> None:
314
+ """``REINDEX DATABASE`` the target database."""
315
+ self._exec_in(db, f"REINDEX DATABASE {quote_identifier(db)}")
316
+
317
+ def _exec_in(self, db: str, *statements: str) -> None:
318
+ """Run autocommit statements against ``db`` itself (not the maintenance db)."""
319
+ engine = postgres.initialize(config=self._engine_config(db))
320
+
321
+ @engine.transaction
322
+ def _run(tx):
323
+ tx.rollback()
324
+ tx.connection.autocommit = True
325
+ for sql in statements:
326
+ if sql:
327
+ tx.execute(sql)
328
+
329
+ _run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.75
3
+ Version: 0.1.77
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
@@ -34,6 +34,7 @@ src/velocity/aws/tests/test_lambda_handler_json_serialization.py
34
34
  src/velocity/aws/tests/test_response.py
35
35
  src/velocity/db/__init__.py
36
36
  src/velocity/db/exceptions.py
37
+ src/velocity/db/maintenance.py
37
38
  src/velocity/db/migrations.py
38
39
  src/velocity/db/utils.py
39
40
  src/velocity/db/core/__init__.py
@@ -162,6 +163,7 @@ tests/test_connection_pool.py
162
163
  tests/test_connection_resilience.py
163
164
  tests/test_context_job_descriptions.py
164
165
  tests/test_db_credentials_ssm_cascade.py
166
+ tests/test_db_maintenance.py
165
167
  tests/test_decorators.py
166
168
  tests/test_dirty_pipeline_fast_path.py
167
169
  tests/test_email_processing.py
@@ -0,0 +1,198 @@
1
+ import unittest
2
+
3
+ from velocity.db import maintenance
4
+
5
+
6
+ class _RecordingMaintenance(maintenance.DatabaseMaintenance):
7
+ """In-memory model: records emitted SQL and tracks database existence so the
8
+ internal exists()/drop()/rename() checks stay consistent -- no live DB."""
9
+
10
+ def __init__(self, *, existing=(), snapshots=(), **kwargs):
11
+ super().__init__(**kwargs)
12
+ self._existing = set(existing)
13
+ self._snapshots = list(snapshots)
14
+ self.executed = []
15
+
16
+ def _exec(self, *statements):
17
+ self.executed.extend(s for s in statements if s)
18
+
19
+ def exists(self, db):
20
+ return db in self._existing
21
+
22
+ def list_snapshots(self, db):
23
+ return sorted(self._snapshots)
24
+
25
+ # state-tracking wrappers (call through to the real logic, then update state)
26
+ def clone(self, source, dest, **kwargs):
27
+ result = super().clone(source, dest, **kwargs)
28
+ self._existing.add(dest)
29
+ return result
30
+
31
+ def drop(self, db, **kwargs):
32
+ super().drop(db, **kwargs)
33
+ self._existing.discard(db)
34
+
35
+ def rename(self, old, new):
36
+ result = super().rename(old, new)
37
+ if old in self._existing:
38
+ self._existing.discard(old)
39
+ self._existing.add(new)
40
+ return result
41
+
42
+ @property
43
+ def sql(self):
44
+ return " | ".join(self.executed)
45
+
46
+ def _index(self, needle):
47
+ return next(i for i, s in enumerate(self.executed) if needle in s)
48
+
49
+
50
+ class TestClone(unittest.TestCase):
51
+ def test_emits_terminate_then_template(self):
52
+ m = _RecordingMaintenance()
53
+ m.clone("src", "dst")
54
+ self.assertIn("pg_terminate_backend", m.sql)
55
+ self.assertIn('CREATE DATABASE "dst" WITH TEMPLATE "src"', m.sql)
56
+ # source connections are terminated BEFORE the template clone
57
+ self.assertLess(self.m_terminate_src(m), m._index("CREATE DATABASE"))
58
+
59
+ @staticmethod
60
+ def m_terminate_src(m):
61
+ return next(i for i, s in enumerate(m.executed) if "pg_terminate" in s and "'src'" in s)
62
+
63
+ def test_existing_without_drop_raises(self):
64
+ m = _RecordingMaintenance(existing={"dst"})
65
+ with self.assertRaises(ValueError):
66
+ m.clone("src", "dst")
67
+
68
+ def test_existing_with_drop_replaces(self):
69
+ m = _RecordingMaintenance(existing={"dst"})
70
+ m.clone("src", "dst", drop_existing=True)
71
+ self.assertIn('DROP DATABASE IF EXISTS "dst"', m.sql)
72
+ self.assertIn('CREATE DATABASE "dst" WITH TEMPLATE "src"', m.sql)
73
+ self.assertLess(m._index("DROP DATABASE"), m._index("CREATE DATABASE"))
74
+
75
+
76
+ class TestRefresh(unittest.TestCase):
77
+ def test_keeps_snapshot_then_clones(self):
78
+ m = _RecordingMaintenance(existing={"dev"})
79
+ m.refresh("prod", "dev")
80
+ self.assertRegex(
81
+ m.sql, r'ALTER DATABASE "dev" RENAME TO "dev-snapshot-\d{8}-\d{6}"'
82
+ )
83
+ self.assertIn('CREATE DATABASE "dev" WITH TEMPLATE "prod"', m.sql)
84
+ self.assertLess(m._index("RENAME TO"), m._index("CREATE DATABASE"))
85
+
86
+ def test_no_snapshot_drops(self):
87
+ m = _RecordingMaintenance(existing={"dev"})
88
+ m.refresh("prod", "dev", keep_snapshot=False)
89
+ self.assertIn('DROP DATABASE IF EXISTS "dev"', m.sql)
90
+ self.assertNotIn("RENAME TO", m.sql)
91
+ self.assertIn('CREATE DATABASE "dev" WITH TEMPLATE "prod"', m.sql)
92
+
93
+ def test_dest_absent_just_clones(self):
94
+ m = _RecordingMaintenance(existing=set())
95
+ m.refresh("prod", "dev")
96
+ self.assertNotIn("RENAME TO", m.sql)
97
+ self.assertNotIn("DROP DATABASE", m.sql)
98
+ self.assertIn('CREATE DATABASE "dev" WITH TEMPLATE "prod"', m.sql)
99
+
100
+
101
+ class TestSnapshotAndPrune(unittest.TestCase):
102
+ def test_snapshot_custom_label(self):
103
+ m = _RecordingMaintenance()
104
+ name = m.snapshot("dev", label="manual")
105
+ self.assertEqual(name, "dev-snapshot-manual")
106
+ self.assertIn('CREATE DATABASE "dev-snapshot-manual" WITH TEMPLATE "dev"', m.sql)
107
+
108
+ def test_prune_keeps_newest(self):
109
+ snaps = [
110
+ "dev-snapshot-20260101-000000",
111
+ "dev-snapshot-20260201-000000",
112
+ "dev-snapshot-20260301-000000",
113
+ ]
114
+ m = _RecordingMaintenance(snapshots=snaps)
115
+ dropped = m.prune_snapshots("dev", keep=1)
116
+ self.assertEqual(dropped, snaps[:2])
117
+ self.assertIn('DROP DATABASE IF EXISTS "dev-snapshot-20260101-000000"', m.sql)
118
+ self.assertNotIn("20260301", m.sql) # newest is kept
119
+
120
+ def test_prune_dry_run_executes_nothing(self):
121
+ snaps = ["dev-snapshot-20260101-000000", "dev-snapshot-20260201-000000"]
122
+ m = _RecordingMaintenance(snapshots=snaps)
123
+ dropped = m.prune_snapshots("dev", keep=1, dry_run=True)
124
+ self.assertEqual(dropped, snaps[:1])
125
+ self.assertEqual(m.executed, [])
126
+
127
+ def test_prune_older_than_days(self):
128
+ snaps = ["dev-snapshot-20200101-000000", "dev-snapshot-29990101-000000"]
129
+ m = _RecordingMaintenance(snapshots=snaps)
130
+ dropped = m.prune_snapshots("dev", older_than_days=30)
131
+ self.assertEqual(dropped, ["dev-snapshot-20200101-000000"])
132
+
133
+ def test_prune_keep_all_when_under_limit(self):
134
+ snaps = ["dev-snapshot-20260101-000000"]
135
+ m = _RecordingMaintenance(snapshots=snaps)
136
+ self.assertEqual(m.prune_snapshots("dev", keep=3), [])
137
+ self.assertEqual(m.executed, [])
138
+
139
+
140
+ class TestGuards(unittest.TestCase):
141
+ def test_protected_blocks_drop(self):
142
+ m = _RecordingMaintenance(existing={"caringcent-production"})
143
+ with self.assertRaises(PermissionError):
144
+ m.drop("caringcent-production")
145
+
146
+ def test_protected_blocks_overwrite_via_clone(self):
147
+ m = _RecordingMaintenance()
148
+ with self.assertRaises(PermissionError):
149
+ m.clone("x", "caringcent-production")
150
+
151
+ def test_protected_blocks_refresh_dest(self):
152
+ m = _RecordingMaintenance()
153
+ with self.assertRaises(PermissionError):
154
+ m.refresh("x", "caringcent-production")
155
+
156
+ def test_protected_blocks_rename(self):
157
+ m = _RecordingMaintenance(existing={"caringcent-production"})
158
+ with self.assertRaises(PermissionError):
159
+ m.rename("caringcent-production", "x")
160
+
161
+ def test_override_protected_allows(self):
162
+ m = _RecordingMaintenance(existing={"caringcent-production"}, protected=())
163
+ m.drop("caringcent-production")
164
+ self.assertIn('DROP DATABASE IF EXISTS "caringcent-production"', m.sql)
165
+
166
+
167
+ class TestQuoting(unittest.TestCase):
168
+ def test_identifier_quoting_doubles_double_quotes(self):
169
+ self.assertEqual(maintenance.quote_identifier('a"b'), '"a""b"')
170
+
171
+ def test_literal_quoting_doubles_single_quotes(self):
172
+ self.assertEqual(maintenance.quote_literal("a'b"), "'a''b'")
173
+
174
+ def test_terminate_sql_escapes_name(self):
175
+ sql = maintenance.DatabaseMaintenance._terminate_sql("o'brien")
176
+ self.assertIn("datname = 'o''brien'", sql)
177
+ self.assertIn("pid <> pg_backend_pid()", sql)
178
+
179
+
180
+ class TestEngineConfig(unittest.TestCase):
181
+ def test_forces_maintenance_db_and_drops_caller_db_name(self):
182
+ m = maintenance.DatabaseMaintenance(
183
+ config={"database": "caringcent-develop", "host": "h", "user": "u"}
184
+ )
185
+ cfg = m._engine_config(m.maintenance_db)
186
+ self.assertEqual(cfg["dbname"], "postgres")
187
+ self.assertNotIn("database", cfg) # the stray key that would override dbname is gone
188
+ self.assertEqual(cfg["host"], "h")
189
+ self.assertEqual(cfg["user"], "u")
190
+
191
+ def test_target_db_overrides_caller_dbname(self):
192
+ m = maintenance.DatabaseMaintenance(config={"dbname": "x", "host": "h"})
193
+ cfg = m._engine_config("mydb")
194
+ self.assertEqual(cfg["dbname"], "mydb")
195
+
196
+
197
+ if __name__ == "__main__":
198
+ unittest.main()