velocity-python 0.1.93__tar.gz → 0.1.95__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.93 → velocity_python-0.1.95}/PKG-INFO +2 -2
- {velocity_python-0.1.93 → velocity_python-0.1.95}/README.md +1 -1
- {velocity_python-0.1.93 → velocity_python-0.1.95}/pyproject.toml +1 -1
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/mixins/__init__.py +3 -0
- velocity_python-0.1.95/src/velocity/aws/handlers/mixins/job_handler.py +175 -0
- velocity_python-0.1.95/src/velocity/aws/job_results.py +453 -0
- velocity_python-0.1.95/src/velocity/aws/sms.py +237 -0
- velocity_python-0.1.95/src/velocity/aws/tests/test_sms.py +178 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/PKG-INFO +2 -2
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/SOURCES.txt +6 -0
- velocity_python-0.1.95/tests/test_job_handler.py +162 -0
- velocity_python-0.1.95/tests/test_job_results.py +243 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/LICENSE +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/setup.cfg +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/backfill.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/config.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/operations.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/policies.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/reconcile.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/deploy/targets.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/masquerade.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/jsonproxy.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/maintenance.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/core/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/core/capabilities.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/core/store.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/core/where.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/exceptions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/base/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/memory/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/nosql/servers/redis/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_activity_tracking_aborted_tx.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_assets_service.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_column_tx_arg.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_db_maintenance.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_delete_hook_fk_blocked.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_dirty_pipeline_fast_path.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_enqueue_send_failures.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_get_cognito_user_provider.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_http_handler_rollback.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_identifier_injection_guard.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_json_columns.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_lambda_handler_masquerade.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_masquerade_grant.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_dynamodb.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_memory.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_mongodb.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_redis.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_translation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_nosql_where.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_observability.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_on_commit.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_mirror_empty_table.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_restricted_direct_tables.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_retry_side_effect_guard.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_single_autocommit_safety.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_sqlite_backend.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_store_user_data.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_transaction_class_wrapping.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_transaction_commit_and_ownership.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_transaction_edge_cases.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/tests/test_where_clause_validation.py +0 -0
- {velocity_python-0.1.93 → velocity_python-0.1.95}/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.
|
|
3
|
+
Version: 0.1.95
|
|
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
|
|
@@ -250,7 +250,7 @@ Full documentation is included in the `docs/` directory of the source distributi
|
|
|
250
250
|
| Performance & Optimization | `docs/performance.md` | Connection pooling, batch operations, query caching, prepared statements, N+1 prevention, observability |
|
|
251
251
|
| Async Support | `docs/async.md` | AsyncTransaction, AsyncTable, AsyncResult, parallel queries with gather() |
|
|
252
252
|
| Configuration Reference | `docs/configuration.md` | All environment variables, engine options, and connection settings |
|
|
253
|
-
| AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions |
|
|
253
|
+
| AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions, async jobs (JobHandler + job result stores) |
|
|
254
254
|
| NoSQL Adapters | `docs/nosql.md` | Redis, DynamoDB, MongoDB, Elasticsearch, Cassandra, Couchbase behind one capability-honest contract |
|
|
255
255
|
| Payment Processing | `docs/payment.md` | Stripe and Braintree adapters, payment lifecycle |
|
|
256
256
|
| Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing |
|
|
@@ -156,7 +156,7 @@ Full documentation is included in the `docs/` directory of the source distributi
|
|
|
156
156
|
| Performance & Optimization | `docs/performance.md` | Connection pooling, batch operations, query caching, prepared statements, N+1 prevention, observability |
|
|
157
157
|
| Async Support | `docs/async.md` | AsyncTransaction, AsyncTable, AsyncResult, parallel queries with gather() |
|
|
158
158
|
| Configuration Reference | `docs/configuration.md` | All environment variables, engine options, and connection settings |
|
|
159
|
-
| AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions |
|
|
159
|
+
| AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions, async jobs (JobHandler + job result stores) |
|
|
160
160
|
| NoSQL Adapters | `docs/nosql.md` | Redis, DynamoDB, MongoDB, Elasticsearch, Cassandra, Couchbase behind one capability-honest contract |
|
|
161
161
|
| Payment Processing | `docs/payment.md` | Stripe and Braintree adapters, payment lifecycle |
|
|
162
162
|
| Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing |
|
{velocity_python-0.1.93 → velocity_python-0.1.95}/src/velocity/aws/handlers/mixins/__init__.py
RENAMED
|
@@ -5,13 +5,16 @@ This package provides mixins for common Lambda handler patterns:
|
|
|
5
5
|
- WebHandler: Activity tracking, logging, and error handling
|
|
6
6
|
- ButtonHandler: Dynamic button action handler loading
|
|
7
7
|
- DataServiceMixin: Generic CRUD operations for database-backed APIs
|
|
8
|
+
- JobHandler: get-job-status / download-job-result / cancel-job for async jobs
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from .web_handler import WebHandler, ButtonHandler
|
|
11
12
|
from .data_service import DataServiceMixin
|
|
13
|
+
from .job_handler import JobHandler
|
|
12
14
|
|
|
13
15
|
__all__ = [
|
|
14
16
|
"WebHandler",
|
|
15
17
|
"ButtonHandler",
|
|
16
18
|
"DataServiceMixin",
|
|
19
|
+
"JobHandler",
|
|
17
20
|
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""JobHandler — the web-tier half of asynchronous jobs.
|
|
2
|
+
|
|
3
|
+
velocity-react's job watcher (``JobProgressPortal`` + ``startJobWatcher``) polls
|
|
4
|
+
three ``/event`` actions after a response carries ``jobs.events``:
|
|
5
|
+
|
|
6
|
+
* ``get-job-status`` → ``{"job_status": {job_id, status, message, batch_id, updated}}``
|
|
7
|
+
* ``download-job-result`` → a ``file-download`` (or ``redirect``) response action
|
|
8
|
+
* ``cancel-job`` → flags the job ``CancelRequested`` for the worker
|
|
9
|
+
|
|
10
|
+
They are identical for every app, so they live here (extracted from
|
|
11
|
+
BackOfficeEvents). Mix into the HTTP handler alongside ``WebHandler``::
|
|
12
|
+
|
|
13
|
+
class HttpEventHandler(ButtonHandler, JobHandler, WebHandler, LambdaHandler):
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
A button action that wants the watcher enqueues, then announces the job::
|
|
17
|
+
|
|
18
|
+
result = context.enqueue("zip-invoices", payload)
|
|
19
|
+
job_id = result["Successful"][0]["Id"]
|
|
20
|
+
context.response().jobs.start(job_id=job_id, label="Zipping invoices")
|
|
21
|
+
|
|
22
|
+
and the worker persists its output with ``velocity.aws.job_results.store_job_result``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import json
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
|
|
31
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
32
|
+
from velocity.aws.job_results import (
|
|
33
|
+
deliver_job_result,
|
|
34
|
+
job_result_files,
|
|
35
|
+
parse_job_message,
|
|
36
|
+
)
|
|
37
|
+
from velocity.misc.format import to_json
|
|
38
|
+
|
|
39
|
+
TERMINAL_JOB_STATUSES = frozenset({"Completed", "Failed", "Cancelled"})
|
|
40
|
+
# Statuses that can only persist this long because the worker died without
|
|
41
|
+
# running onError (Lambda timeout / OOM): synthesize Failed so the UI moves on.
|
|
42
|
+
STALE_JOB_STATUSES = frozenset({"Running", "Initialized"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class JobHandler:
|
|
46
|
+
JOB_ACTIVITY_TABLE = "aws_job_activity"
|
|
47
|
+
JOB_STALE_AFTER = datetime.timedelta(minutes=16)
|
|
48
|
+
|
|
49
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _job_id_from(self, context) -> str:
|
|
52
|
+
payload = context.payload() or {}
|
|
53
|
+
job_id = payload.get("job_id") if isinstance(payload, dict) else None
|
|
54
|
+
if not job_id:
|
|
55
|
+
raise AlertError("job_id is required")
|
|
56
|
+
return str(job_id)
|
|
57
|
+
|
|
58
|
+
def _find_job(self, tx, job_id: str):
|
|
59
|
+
return tx.table(self.JOB_ACTIVITY_TABLE).find({"job_id": job_id})
|
|
60
|
+
|
|
61
|
+
def _job_is_stale(self, job) -> bool:
|
|
62
|
+
if (job.get("status") or "") not in STALE_JOB_STATUSES:
|
|
63
|
+
return False
|
|
64
|
+
ts = job.get("sys_modified") or job.get("initial_timestamp")
|
|
65
|
+
if ts is None:
|
|
66
|
+
return False
|
|
67
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
68
|
+
if ts.tzinfo is None:
|
|
69
|
+
ts = ts.replace(tzinfo=datetime.timezone.utc)
|
|
70
|
+
return (now - ts) > self.JOB_STALE_AFTER
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _job_status_body(
|
|
74
|
+
job_id: str,
|
|
75
|
+
status: str,
|
|
76
|
+
message: Optional[Dict[str, Any]],
|
|
77
|
+
job=None,
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
body = {"job_id": job_id, "status": status, "message": message}
|
|
80
|
+
if job is not None:
|
|
81
|
+
body["batch_id"] = job.get("batch_id")
|
|
82
|
+
body["updated"] = job.get("sys_modified")
|
|
83
|
+
return {"job_status": body}
|
|
84
|
+
|
|
85
|
+
# ── actions ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def OnActionGetJobStatus(self, tx, context):
|
|
88
|
+
"""Current status + message for one job (polled by the job watcher)."""
|
|
89
|
+
job_id = self._job_id_from(context)
|
|
90
|
+
job = self._find_job(tx, job_id)
|
|
91
|
+
if not job:
|
|
92
|
+
context.response().set_body(
|
|
93
|
+
self._job_status_body(
|
|
94
|
+
job_id, "NotFound", {"detail": "Job not found"}
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if self._job_is_stale(job):
|
|
100
|
+
context.response().set_body(
|
|
101
|
+
self._job_status_body(
|
|
102
|
+
job_id,
|
|
103
|
+
"Failed",
|
|
104
|
+
{
|
|
105
|
+
"detail": "Job timed out — the server ran out of time or memory.",
|
|
106
|
+
"error": "Lambda timed out or was killed (OOM)",
|
|
107
|
+
},
|
|
108
|
+
job,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
message = parse_job_message(job.get("message")) or None
|
|
114
|
+
context.response().set_body(
|
|
115
|
+
self._job_status_body(job_id, job.get("status") or "", message, job)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def OnActionDownloadJobResult(self, tx, context):
|
|
119
|
+
"""Deliver a completed job's file via the store that holds it."""
|
|
120
|
+
job_id = self._job_id_from(context)
|
|
121
|
+
job = self._find_job(tx, job_id)
|
|
122
|
+
if not job:
|
|
123
|
+
raise AlertError("Job result not found")
|
|
124
|
+
|
|
125
|
+
files = job_result_files(job.get("message"))
|
|
126
|
+
if not files:
|
|
127
|
+
raise AlertError("Job result is not ready")
|
|
128
|
+
|
|
129
|
+
# One file per job result; descriptors without a store are legacy
|
|
130
|
+
# BackOffice S3 entries.
|
|
131
|
+
descriptor = dict(files[0])
|
|
132
|
+
descriptor.setdefault("store", "s3" if descriptor.get("bucket") else "db")
|
|
133
|
+
deliver_job_result(tx, context, descriptor)
|
|
134
|
+
context.response().toast("Download ready.")
|
|
135
|
+
|
|
136
|
+
def OnActionCancelJob(self, tx, context):
|
|
137
|
+
"""Ask the worker to stop: status CancelRequested + message flag.
|
|
138
|
+
|
|
139
|
+
Workers poll ``context.is_job_cancel_requested(tx)`` between units of
|
|
140
|
+
work and call ``context.mark_job_cancelled`` when they honour it.
|
|
141
|
+
"""
|
|
142
|
+
job_id = self._job_id_from(context)
|
|
143
|
+
job = self._find_job(tx, job_id)
|
|
144
|
+
if not job:
|
|
145
|
+
raise AlertError("Job not found")
|
|
146
|
+
|
|
147
|
+
current = job.get("status") or ""
|
|
148
|
+
if current in TERMINAL_JOB_STATUSES:
|
|
149
|
+
raise AlertError(f"Job is already {current.lower()}")
|
|
150
|
+
|
|
151
|
+
payload = context.payload() or {}
|
|
152
|
+
message = parse_job_message(job.get("message"))
|
|
153
|
+
message.update(
|
|
154
|
+
{
|
|
155
|
+
"detail": payload.get("detail")
|
|
156
|
+
or payload.get("reason")
|
|
157
|
+
or "Cancellation requested by user",
|
|
158
|
+
"cancel_requested": True,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
tx.table(self.JOB_ACTIVITY_TABLE).update(
|
|
162
|
+
{
|
|
163
|
+
"status": "CancelRequested",
|
|
164
|
+
"message": to_json(message),
|
|
165
|
+
"sys_modified": datetime.datetime.now(),
|
|
166
|
+
"sys_modified_by": (context.session() or {}).get("email_address"),
|
|
167
|
+
},
|
|
168
|
+
{"job_id": job_id},
|
|
169
|
+
)
|
|
170
|
+
context.response().set_body(
|
|
171
|
+
self._job_status_body(job_id, "CancelRequested", message)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = ["JobHandler", "TERMINAL_JOB_STATUSES", "STALE_JOB_STATUSES"]
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Result storage for asynchronous jobs.
|
|
2
|
+
|
|
3
|
+
A button action that may outrun the API Gateway budget enqueues work instead
|
|
4
|
+
(``context.enqueue``), the queue worker produces a file, and the browser — which
|
|
5
|
+
velocity-react is already polling via ``get-job-status`` — fetches it with
|
|
6
|
+
``download-job-result``. This module is the seam between the two halves:
|
|
7
|
+
|
|
8
|
+
* workers call :func:`store_job_result` — persist the bytes through a
|
|
9
|
+
:class:`JobResultStore` and record a *file descriptor* on the job's
|
|
10
|
+
``aws_job_activity.message.result.files`` (the shape BackOffice's export
|
|
11
|
+
pipeline established, so existing UI code keeps working);
|
|
12
|
+
* the web tier (``JobHandler.OnActionDownloadJobResult``) calls
|
|
13
|
+
:func:`deliver_job_result`, which routes the descriptor back to the store
|
|
14
|
+
that wrote it and emits the response action that gets the file to the user.
|
|
15
|
+
|
|
16
|
+
Two stores ship:
|
|
17
|
+
|
|
18
|
+
``DbJobResultStore`` (default)
|
|
19
|
+
Bytes live in the ``aws_job_results`` table and are delivered inline as a
|
|
20
|
+
``file-download`` action (base64 over API Gateway). No infrastructure
|
|
21
|
+
beyond the database, but bounded by the ~6 MB Lambda response limit — see
|
|
22
|
+
``max_inline_bytes``.
|
|
23
|
+
``S3JobResultStore``
|
|
24
|
+
Bytes go to a bucket and are delivered as a short-lived presigned GET via a
|
|
25
|
+
``redirect`` action — no size ceiling, needs a bucket + IAM.
|
|
26
|
+
|
|
27
|
+
Stores are registered by name (``descriptor["store"]``) so an app can swap
|
|
28
|
+
backends without touching its workers: ``register_job_result_store(
|
|
29
|
+
S3JobResultStore("my-bucket"), default=True)``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import base64
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from abc import ABC, abstractmethod
|
|
38
|
+
from datetime import datetime, timedelta, timezone
|
|
39
|
+
from typing import Any, Dict, Optional
|
|
40
|
+
|
|
41
|
+
from velocity.misc.format import to_json
|
|
42
|
+
|
|
43
|
+
# ``velocity.aws.handlers`` initializes a DB engine when its package is imported,
|
|
44
|
+
# so the user-facing error type is imported lazily: workers, schema scripts and
|
|
45
|
+
# tests can import this module without database configuration.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _alert(message: str) -> Exception:
|
|
49
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
50
|
+
|
|
51
|
+
return AlertError(message)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"JOB_RESULTS_TABLE",
|
|
55
|
+
"JOB_RESULTS_SCHEMA",
|
|
56
|
+
"JOB_RESULTS_INDEXES",
|
|
57
|
+
"JobResultStore",
|
|
58
|
+
"DbJobResultStore",
|
|
59
|
+
"S3JobResultStore",
|
|
60
|
+
"register_job_result_store",
|
|
61
|
+
"get_job_result_store",
|
|
62
|
+
"default_job_result_store",
|
|
63
|
+
"registered_job_result_stores",
|
|
64
|
+
"store_job_result",
|
|
65
|
+
"record_job_result",
|
|
66
|
+
"deliver_job_result",
|
|
67
|
+
"parse_job_message",
|
|
68
|
+
"job_result_files",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
JOB_RESULTS_TABLE = "aws_job_results"
|
|
72
|
+
|
|
73
|
+
# Declared SQL types so an app's schema lane can create the table up front in
|
|
74
|
+
# locked environments (velocity also creates it lazily on first write in dev).
|
|
75
|
+
JOB_RESULTS_SCHEMA: Dict[str, str] = {
|
|
76
|
+
"job_id": "TEXT",
|
|
77
|
+
"filename": "TEXT",
|
|
78
|
+
"content_type": "TEXT",
|
|
79
|
+
"payload": "BYTEA",
|
|
80
|
+
"size_bytes": "BIGINT",
|
|
81
|
+
"created_by": "TEXT",
|
|
82
|
+
"expires_at": "TIMESTAMPTZ",
|
|
83
|
+
}
|
|
84
|
+
JOB_RESULTS_INDEXES = (
|
|
85
|
+
(
|
|
86
|
+
"aws_job_results_job_id_idx",
|
|
87
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS aws_job_results_job_id_idx "
|
|
88
|
+
"ON aws_job_results (job_id)",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
_SAFE_FILENAME = re.compile(r"[^A-Za-z0-9._ -]+")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_filename(filename: Optional[str], default: str = "download") -> str:
|
|
96
|
+
"""Last path segment only, restricted to a conservative character set."""
|
|
97
|
+
name = re.split(r"[\\/]+", str(filename or "").strip())[-1]
|
|
98
|
+
name = _SAFE_FILENAME.sub("_", name).strip(" .")
|
|
99
|
+
return name or default
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_job_message(raw: Any) -> Dict[str, Any]:
|
|
103
|
+
"""``aws_job_activity.message`` as a dict (it is stored as JSON text, but
|
|
104
|
+
legacy rows carry plain strings — those become ``{"detail": text}``)."""
|
|
105
|
+
if raw is None or raw == "":
|
|
106
|
+
return {}
|
|
107
|
+
if isinstance(raw, dict):
|
|
108
|
+
return dict(raw)
|
|
109
|
+
try:
|
|
110
|
+
parsed = json.loads(raw)
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
return {"detail": str(raw)}
|
|
113
|
+
return parsed if isinstance(parsed, dict) else {"detail": str(raw)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def job_result_files(message: Any) -> list:
|
|
117
|
+
"""The ``result.files`` descriptors recorded on a job message, or ``[]``."""
|
|
118
|
+
parsed = parse_job_message(message)
|
|
119
|
+
result = parsed.get("result")
|
|
120
|
+
if not isinstance(result, dict):
|
|
121
|
+
return []
|
|
122
|
+
files = result.get("files")
|
|
123
|
+
return [f for f in files if isinstance(f, dict)] if isinstance(files, list) else []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class JobResultStore(ABC):
|
|
127
|
+
"""Persist a job's output file and deliver it back to the browser later.
|
|
128
|
+
|
|
129
|
+
``put`` returns a JSON-safe *descriptor* — at minimum ``store`` (this
|
|
130
|
+
store's name), ``filename``, ``contentType`` and ``size`` — which is what
|
|
131
|
+
gets recorded on the job and handed back to ``deliver``.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
name: str = ""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
def put(
|
|
138
|
+
self,
|
|
139
|
+
tx,
|
|
140
|
+
*,
|
|
141
|
+
job_id: str,
|
|
142
|
+
filename: str,
|
|
143
|
+
payload: bytes,
|
|
144
|
+
content_type: str = "application/octet-stream",
|
|
145
|
+
created_by: Optional[str] = None,
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""Persist ``payload`` for ``job_id`` and return its descriptor."""
|
|
148
|
+
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def deliver(self, tx, context, descriptor: Dict[str, Any]) -> None:
|
|
151
|
+
"""Emit the response action(s) that get the described file to the user."""
|
|
152
|
+
|
|
153
|
+
def _descriptor(self, **fields) -> Dict[str, Any]:
|
|
154
|
+
base = {"store": self.name}
|
|
155
|
+
base.update({k: v for k, v in fields.items() if v is not None})
|
|
156
|
+
return base
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DbJobResultStore(JobResultStore):
|
|
160
|
+
"""Results stored as ``bytea`` rows in ``aws_job_results`` and delivered
|
|
161
|
+
inline as a ``file-download`` action.
|
|
162
|
+
|
|
163
|
+
``max_inline_bytes`` guards the Lambda/API Gateway response ceiling (6 MB
|
|
164
|
+
after base64 inflation ≈ 4.5 MB of file); larger outputs need the S3 store.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
name = "db"
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
table: str = JOB_RESULTS_TABLE,
|
|
172
|
+
*,
|
|
173
|
+
max_inline_bytes: int = 4_500_000,
|
|
174
|
+
retention: Optional[timedelta] = timedelta(days=7),
|
|
175
|
+
):
|
|
176
|
+
self.table = table
|
|
177
|
+
self.max_inline_bytes = max_inline_bytes
|
|
178
|
+
self.retention = retention
|
|
179
|
+
|
|
180
|
+
def put(
|
|
181
|
+
self,
|
|
182
|
+
tx,
|
|
183
|
+
*,
|
|
184
|
+
job_id,
|
|
185
|
+
filename,
|
|
186
|
+
payload,
|
|
187
|
+
content_type="application/octet-stream",
|
|
188
|
+
created_by=None,
|
|
189
|
+
):
|
|
190
|
+
if not job_id:
|
|
191
|
+
raise ValueError("DbJobResultStore.put requires a job_id")
|
|
192
|
+
payload = bytes(payload)
|
|
193
|
+
if len(payload) > self.max_inline_bytes:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"Job result is {len(payload):,} bytes; the database store delivers "
|
|
196
|
+
f"inline and is capped at {self.max_inline_bytes:,} bytes. Use "
|
|
197
|
+
"S3JobResultStore for outputs this large."
|
|
198
|
+
)
|
|
199
|
+
filename = _safe_filename(filename)
|
|
200
|
+
now = datetime.now(timezone.utc)
|
|
201
|
+
row = {
|
|
202
|
+
"filename": filename,
|
|
203
|
+
"content_type": content_type,
|
|
204
|
+
"payload": payload,
|
|
205
|
+
"size_bytes": len(payload),
|
|
206
|
+
"created_by": created_by,
|
|
207
|
+
"expires_at": (now + self.retention) if self.retention else None,
|
|
208
|
+
}
|
|
209
|
+
tx.table(self.table).upsert(row, {"job_id": str(job_id)})
|
|
210
|
+
return self._descriptor(
|
|
211
|
+
table=self.table,
|
|
212
|
+
job_id=str(job_id),
|
|
213
|
+
filename=filename,
|
|
214
|
+
contentType=content_type,
|
|
215
|
+
size=len(payload),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def deliver(self, tx, context, descriptor):
|
|
219
|
+
job_id = descriptor.get("job_id")
|
|
220
|
+
if not job_id:
|
|
221
|
+
raise _alert("Job result descriptor is missing its job_id")
|
|
222
|
+
row = tx.table(descriptor.get("table") or self.table).find({"job_id": job_id})
|
|
223
|
+
if not row:
|
|
224
|
+
raise _alert("Job result is no longer available")
|
|
225
|
+
payload = row["payload"]
|
|
226
|
+
if isinstance(payload, memoryview):
|
|
227
|
+
payload = payload.tobytes()
|
|
228
|
+
if payload is None:
|
|
229
|
+
raise _alert("Job result is empty")
|
|
230
|
+
payload = bytes(payload)
|
|
231
|
+
if len(payload) > self.max_inline_bytes:
|
|
232
|
+
raise _alert(
|
|
233
|
+
"Job result is too large to download inline; ask an administrator "
|
|
234
|
+
"to enable S3-backed job results."
|
|
235
|
+
)
|
|
236
|
+
filename = descriptor.get("filename") or row.get("filename") or "download"
|
|
237
|
+
content_type = (
|
|
238
|
+
descriptor.get("contentType")
|
|
239
|
+
or row.get("content_type")
|
|
240
|
+
or "application/octet-stream"
|
|
241
|
+
)
|
|
242
|
+
context.response().file_download(
|
|
243
|
+
{
|
|
244
|
+
"filename": filename,
|
|
245
|
+
"data": base64.b64encode(payload).decode(),
|
|
246
|
+
"mimeType": content_type,
|
|
247
|
+
"contentType": content_type,
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def purge_expired(self, tx, now: Optional[datetime] = None) -> int:
|
|
252
|
+
"""Delete rows past ``expires_at``; returns the number removed."""
|
|
253
|
+
now = now or datetime.now(timezone.utc)
|
|
254
|
+
return tx.table(self.table).delete({"<expires_at": now}) or 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class S3JobResultStore(JobResultStore):
|
|
258
|
+
"""Results uploaded to S3 and delivered as a presigned GET ``redirect``."""
|
|
259
|
+
|
|
260
|
+
name = "s3"
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
bucket: str,
|
|
265
|
+
*,
|
|
266
|
+
prefix: str = "job-results",
|
|
267
|
+
expires_in: int = 300,
|
|
268
|
+
s3_client=None,
|
|
269
|
+
):
|
|
270
|
+
if not bucket:
|
|
271
|
+
raise ValueError("S3JobResultStore requires a bucket name")
|
|
272
|
+
self.bucket = bucket
|
|
273
|
+
self.prefix = prefix.strip("/")
|
|
274
|
+
self.expires_in = expires_in
|
|
275
|
+
self._s3 = s3_client
|
|
276
|
+
|
|
277
|
+
def _client(self):
|
|
278
|
+
if self._s3 is None:
|
|
279
|
+
import boto3
|
|
280
|
+
|
|
281
|
+
self._s3 = boto3.client("s3")
|
|
282
|
+
return self._s3
|
|
283
|
+
|
|
284
|
+
def key_for(self, job_id: str, filename: str) -> str:
|
|
285
|
+
parts = [p for p in (self.prefix, str(job_id), _safe_filename(filename)) if p]
|
|
286
|
+
return "/".join(parts)
|
|
287
|
+
|
|
288
|
+
def put(
|
|
289
|
+
self,
|
|
290
|
+
tx,
|
|
291
|
+
*,
|
|
292
|
+
job_id,
|
|
293
|
+
filename,
|
|
294
|
+
payload,
|
|
295
|
+
content_type="application/octet-stream",
|
|
296
|
+
created_by=None,
|
|
297
|
+
):
|
|
298
|
+
del tx, created_by
|
|
299
|
+
if not job_id:
|
|
300
|
+
raise ValueError("S3JobResultStore.put requires a job_id")
|
|
301
|
+
filename = _safe_filename(filename)
|
|
302
|
+
key = self.key_for(job_id, filename)
|
|
303
|
+
payload = bytes(payload)
|
|
304
|
+
self._client().put_object(
|
|
305
|
+
Bucket=self.bucket,
|
|
306
|
+
Key=key,
|
|
307
|
+
Body=payload,
|
|
308
|
+
ContentType=content_type,
|
|
309
|
+
ContentDisposition=f'attachment; filename="{filename}"',
|
|
310
|
+
)
|
|
311
|
+
return self._descriptor(
|
|
312
|
+
bucket=self.bucket,
|
|
313
|
+
key=key,
|
|
314
|
+
filename=filename,
|
|
315
|
+
contentType=content_type,
|
|
316
|
+
size=len(payload),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def deliver(self, tx, context, descriptor):
|
|
320
|
+
del tx
|
|
321
|
+
from velocity.aws.s3 import generate_presigned_get
|
|
322
|
+
|
|
323
|
+
bucket = descriptor.get("bucket") or self.bucket
|
|
324
|
+
key = descriptor.get("key")
|
|
325
|
+
if not key:
|
|
326
|
+
raise _alert("Job result descriptor is missing its S3 key")
|
|
327
|
+
url = generate_presigned_get(
|
|
328
|
+
bucket,
|
|
329
|
+
key,
|
|
330
|
+
expires_in=self.expires_in,
|
|
331
|
+
filename=descriptor.get("filename") or key.rsplit("/", 1)[-1],
|
|
332
|
+
content_type=descriptor.get("contentType") or "application/octet-stream",
|
|
333
|
+
)
|
|
334
|
+
context.response().redirect(url)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── Registry ──────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
_STORES: Dict[str, JobResultStore] = {}
|
|
340
|
+
_DEFAULT: Optional[str] = None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def register_job_result_store(store: JobResultStore, *, default: bool = False) -> None:
|
|
344
|
+
"""Register ``store`` under ``store.name``; ``default=True`` makes it the
|
|
345
|
+
store :func:`store_job_result` uses when none is passed."""
|
|
346
|
+
global _DEFAULT
|
|
347
|
+
if not getattr(store, "name", None):
|
|
348
|
+
raise ValueError("A job result store must declare a non-empty name")
|
|
349
|
+
_STORES[store.name] = store
|
|
350
|
+
if default or _DEFAULT is None:
|
|
351
|
+
_DEFAULT = store.name
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def registered_job_result_stores() -> Dict[str, JobResultStore]:
|
|
355
|
+
return dict(_STORES)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_job_result_store(name: Optional[str] = None) -> JobResultStore:
|
|
359
|
+
"""The store registered as ``name`` (or the default when ``name`` is None)."""
|
|
360
|
+
key = name or _DEFAULT
|
|
361
|
+
if key is None or key not in _STORES:
|
|
362
|
+
raise _alert(
|
|
363
|
+
f"No job result store registered as {key!r}"
|
|
364
|
+
if name
|
|
365
|
+
else "No default job result store is registered"
|
|
366
|
+
)
|
|
367
|
+
return _STORES[key]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def default_job_result_store() -> JobResultStore:
|
|
371
|
+
return get_job_result_store(None)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# The database store needs nothing configured, so it is the out-of-the-box
|
|
375
|
+
# default; apps register an S3 store when they outgrow inline delivery.
|
|
376
|
+
register_job_result_store(DbJobResultStore(), default=True)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ── Worker / web-tier helpers ─────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def record_job_result(
|
|
383
|
+
context,
|
|
384
|
+
descriptors,
|
|
385
|
+
*,
|
|
386
|
+
detail: Optional[str] = None,
|
|
387
|
+
percent: Optional[float] = 100,
|
|
388
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
389
|
+
) -> Dict[str, Any]:
|
|
390
|
+
"""Write ``result.files`` onto the current job's message (the job is the
|
|
391
|
+
one in ``context.postdata("job_id")``). Returns the message written.
|
|
392
|
+
|
|
393
|
+
Status is left alone: the queue handler's ``afterAction`` flips it to
|
|
394
|
+
``Completed``, and a lifecycle that preserves a message carrying ``result``
|
|
395
|
+
(as BackOffice's does) keeps these descriptors intact.
|
|
396
|
+
"""
|
|
397
|
+
if isinstance(descriptors, dict):
|
|
398
|
+
descriptors = [descriptors]
|
|
399
|
+
message: Dict[str, Any] = {}
|
|
400
|
+
if extra:
|
|
401
|
+
message.update(extra)
|
|
402
|
+
if percent is not None:
|
|
403
|
+
message["percent"] = percent
|
|
404
|
+
if detail:
|
|
405
|
+
message["detail"] = detail
|
|
406
|
+
message["result"] = {"files": list(descriptors)}
|
|
407
|
+
context.update_job({"message": to_json(message)})
|
|
408
|
+
return message
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def store_job_result(
|
|
412
|
+
tx,
|
|
413
|
+
context,
|
|
414
|
+
*,
|
|
415
|
+
filename: str,
|
|
416
|
+
payload: bytes,
|
|
417
|
+
content_type: str = "application/octet-stream",
|
|
418
|
+
store: Optional[JobResultStore] = None,
|
|
419
|
+
detail: Optional[str] = None,
|
|
420
|
+
created_by: Optional[str] = None,
|
|
421
|
+
) -> Optional[Dict[str, Any]]:
|
|
422
|
+
"""Persist a worker's output and record it on the job.
|
|
423
|
+
|
|
424
|
+
Returns the descriptor, or ``None`` when there is no job_id on the context
|
|
425
|
+
(the same code path ran synchronously and nothing needs storing).
|
|
426
|
+
"""
|
|
427
|
+
job_id = context.postdata("job_id")
|
|
428
|
+
if not job_id:
|
|
429
|
+
return None
|
|
430
|
+
store = store or default_job_result_store()
|
|
431
|
+
if created_by is None:
|
|
432
|
+
payload_meta = context.payload() or {}
|
|
433
|
+
created_by = payload_meta.get("requested_by") if isinstance(payload_meta, dict) else None
|
|
434
|
+
descriptor = store.put(
|
|
435
|
+
tx,
|
|
436
|
+
job_id=job_id,
|
|
437
|
+
filename=filename,
|
|
438
|
+
payload=payload,
|
|
439
|
+
content_type=content_type,
|
|
440
|
+
created_by=created_by,
|
|
441
|
+
)
|
|
442
|
+
record_job_result(
|
|
443
|
+
context,
|
|
444
|
+
descriptor,
|
|
445
|
+
detail=detail or f"{descriptor.get('filename')} is ready",
|
|
446
|
+
)
|
|
447
|
+
return descriptor
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def deliver_job_result(tx, context, descriptor: Dict[str, Any]) -> None:
|
|
451
|
+
"""Hand a recorded descriptor back to the store that wrote it."""
|
|
452
|
+
store = get_job_result_store(descriptor.get("store"))
|
|
453
|
+
store.deliver(tx, context, descriptor)
|