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