velocity-python 0.1.93__tar.gz → 0.1.94__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. {velocity_python-0.1.93 → velocity_python-0.1.94}/PKG-INFO +2 -2
  2. {velocity_python-0.1.93 → velocity_python-0.1.94}/README.md +1 -1
  3. {velocity_python-0.1.93 → velocity_python-0.1.94}/pyproject.toml +1 -1
  4. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/__init__.py +1 -1
  5. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/mixins/__init__.py +3 -0
  6. velocity_python-0.1.94/src/velocity/aws/handlers/mixins/job_handler.py +175 -0
  7. velocity_python-0.1.94/src/velocity/aws/job_results.py +453 -0
  8. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/PKG-INFO +2 -2
  9. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/SOURCES.txt +4 -0
  10. velocity_python-0.1.94/tests/test_job_handler.py +162 -0
  11. velocity_python-0.1.94/tests/test_job_results.py +243 -0
  12. {velocity_python-0.1.93 → velocity_python-0.1.94}/LICENSE +0 -0
  13. {velocity_python-0.1.93 → velocity_python-0.1.94}/setup.cfg +0 -0
  14. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/__init__.py +0 -0
  15. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/amplify.py +0 -0
  16. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/amplify_build.py +0 -0
  17. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/__init__.py +0 -0
  18. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/backfill.py +0 -0
  19. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/indexing.py +0 -0
  20. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/references.py +0 -0
  21. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/service.py +0 -0
  22. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/assets/usage_index.py +0 -0
  23. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/__init__.py +0 -0
  24. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/config.py +0 -0
  25. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/operations.py +0 -0
  26. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/policies.py +0 -0
  27. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/reconcile.py +0 -0
  28. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/deploy/targets.py +0 -0
  29. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/dirty_pipeline.py +0 -0
  30. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/__init__.py +0 -0
  31. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/base_handler.py +0 -0
  32. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/context.py +0 -0
  33. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/context_factory.py +0 -0
  34. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/exceptions.py +0 -0
  35. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  36. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/masquerade.py +0 -0
  37. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  38. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  39. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/perf.py +0 -0
  40. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/response.py +0 -0
  41. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  42. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/s3.py +0 -0
  43. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/ssm_config.py +0 -0
  44. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/__init__.py +0 -0
  45. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
  46. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
  47. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  48. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
  49. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
  50. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  51. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/aws/tests/test_response.py +0 -0
  52. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/__init__.py +0 -0
  53. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/__init__.py +0 -0
  54. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/async_support.py +0 -0
  55. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/column.py +0 -0
  56. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/database.py +0 -0
  57. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/decorators.py +0 -0
  58. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/engine.py +0 -0
  59. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/jsonproxy.py +0 -0
  60. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/result.py +0 -0
  61. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/row.py +0 -0
  62. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/sequence.py +0 -0
  63. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/table.py +0 -0
  64. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/transaction.py +0 -0
  65. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/core/view.py +0 -0
  66. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/exceptions.py +0 -0
  67. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/maintenance.py +0 -0
  68. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/migrations.py +0 -0
  69. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/__init__.py +0 -0
  70. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/base/__init__.py +0 -0
  71. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/base/initializer.py +0 -0
  72. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/base/operators.py +0 -0
  73. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/base/sql.py +0 -0
  74. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/base/types.py +0 -0
  75. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/mysql/__init__.py +0 -0
  76. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/mysql/operators.py +0 -0
  77. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/mysql/reserved.py +0 -0
  78. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/mysql/sql.py +0 -0
  79. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/mysql/types.py +0 -0
  80. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/postgres/__init__.py +0 -0
  81. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/postgres/operators.py +0 -0
  82. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/postgres/reserved.py +0 -0
  83. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/postgres/sql.py +0 -0
  84. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/postgres/types.py +0 -0
  85. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  86. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlite/operators.py +0 -0
  87. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  88. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlite/sql.py +0 -0
  89. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlite/types.py +0 -0
  90. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  91. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  92. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  93. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  94. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/sqlserver/types.py +0 -0
  95. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/servers/tablehelper.py +0 -0
  96. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/__init__.py +0 -0
  97. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/common_db_test.py +0 -0
  98. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/__init__.py +0 -0
  99. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/common.py +0 -0
  100. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/conftest.py +0 -0
  101. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_column.py +0 -0
  102. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  103. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_database.py +0 -0
  104. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  105. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  106. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  107. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_result.py +0 -0
  108. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_row.py +0 -0
  109. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  110. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  111. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  112. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  113. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  114. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_table.py +0 -0
  115. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  116. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  117. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/sql/__init__.py +0 -0
  118. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/sql/common.py +0 -0
  119. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  120. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  121. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  122. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_db_utils.py +0 -0
  123. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_postgres.py +0 -0
  124. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  125. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  126. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_result_caching.py +0 -0
  127. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  128. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  129. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  130. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  131. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_sql_builder.py +0 -0
  132. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_tablehelper.py +0 -0
  133. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/tests/test_view_helper.py +0 -0
  134. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/db/utils.py +0 -0
  135. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/logging.py +0 -0
  136. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/__init__.py +0 -0
  137. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/conv/__init__.py +0 -0
  138. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/conv/iconv.py +0 -0
  139. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/conv/oconv.py +0 -0
  140. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/db.py +0 -0
  141. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/export.py +0 -0
  142. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/format.py +0 -0
  143. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/mail.py +0 -0
  144. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/merge.py +0 -0
  145. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/pdf.py +0 -0
  146. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/__init__.py +0 -0
  147. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_db.py +0 -0
  148. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_fix.py +0 -0
  149. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_format.py +0 -0
  150. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_iconv.py +0 -0
  151. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_merge.py +0 -0
  152. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_oconv.py +0 -0
  153. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_original_error.py +0 -0
  154. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tests/test_timer.py +0 -0
  155. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/timer.py +0 -0
  156. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/misc/tools.py +0 -0
  157. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/__init__.py +0 -0
  158. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/core/__init__.py +0 -0
  159. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/core/capabilities.py +0 -0
  160. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/core/store.py +0 -0
  161. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/core/where.py +0 -0
  162. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/exceptions.py +0 -0
  163. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/__init__.py +0 -0
  164. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/base/__init__.py +0 -0
  165. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
  166. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
  167. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
  168. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
  169. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
  170. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
  171. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
  172. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
  173. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/memory/__init__.py +0 -0
  174. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
  175. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
  176. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/nosql/servers/redis/__init__.py +0 -0
  177. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/__init__.py +0 -0
  178. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/authorizenet_adapter.py +0 -0
  179. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/authorizenet_mirror.py +0 -0
  180. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/base_adapter.py +0 -0
  181. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/braintree_adapter.py +0 -0
  182. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/braintree_mirror.py +0 -0
  183. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/charge_rules.py +0 -0
  184. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/stripe_adapter.py +0 -0
  185. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity/payment/stripe_mirror.py +0 -0
  186. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  187. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/entry_points.txt +0 -0
  188. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/requires.txt +0 -0
  189. {velocity_python-0.1.93 → velocity_python-0.1.94}/src/velocity_python.egg-info/top_level.txt +0 -0
  190. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_activity_tracking_aborted_tx.py +0 -0
  191. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_amplify_build.py +0 -0
  192. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_asset_indexing.py +0 -0
  193. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_asset_references.py +0 -0
  194. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_assets_service.py +0 -0
  195. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_async_support.py +0 -0
  196. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_batch_operations.py +0 -0
  197. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_column_tx_arg.py +0 -0
  198. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_concurrency_safety.py +0 -0
  199. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_connection_pool.py +0 -0
  200. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_connection_resilience.py +0 -0
  201. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_context_job_descriptions.py +0 -0
  202. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_db_credentials_ssm_cascade.py +0 -0
  203. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_db_maintenance.py +0 -0
  204. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_decorators.py +0 -0
  205. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_delete_hook_fk_blocked.py +0 -0
  206. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_dirty_pipeline_fast_path.py +0 -0
  207. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_email_processing.py +0 -0
  208. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_enqueue_send_failures.py +0 -0
  209. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_get_cognito_user_provider.py +0 -0
  210. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_http_handler_rollback.py +0 -0
  211. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_iconv_money_to_cents.py +0 -0
  212. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_identifier_injection_guard.py +0 -0
  213. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_json_columns.py +0 -0
  214. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_jsonb_dict_adapter.py +0 -0
  215. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_lambda_handler.py +0 -0
  216. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_lambda_handler_auth.py +0 -0
  217. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_lambda_handler_masquerade.py +0 -0
  218. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_masquerade_grant.py +0 -0
  219. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_mixins_import.py +0 -0
  220. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_n_plus_one.py +0 -0
  221. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_dynamodb.py +0 -0
  222. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_memory.py +0 -0
  223. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_mongodb.py +0 -0
  224. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_redis.py +0 -0
  225. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_translation.py +0 -0
  226. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_nosql_where.py +0 -0
  227. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_observability.py +0 -0
  228. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_on_commit.py +0 -0
  229. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_authorizenet_adapter.py +0 -0
  230. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_braintree_adapter.py +0 -0
  231. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_braintree_mirror.py +0 -0
  232. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_mirror_empty_table.py +0 -0
  233. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_profile_sorting.py +0 -0
  234. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_payment_stripe_adapter.py +0 -0
  235. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_pdf.py +0 -0
  236. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_prepared_statements.py +0 -0
  237. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_psycopg3_upgrade.py +0 -0
  238. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_query_cache.py +0 -0
  239. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_restricted_direct_tables.py +0 -0
  240. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_retry_side_effect_guard.py +0 -0
  241. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_return_default_safety.py +0 -0
  242. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_row_batch_update.py +0 -0
  243. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_row_cache_staleness.py +0 -0
  244. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_schema_migrations.py +0 -0
  245. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_security_hardening.py +0 -0
  246. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_server_cursor.py +0 -0
  247. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_single_autocommit_safety.py +0 -0
  248. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_spreadsheet_functions.py +0 -0
  249. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_sqlite_backend.py +0 -0
  250. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_sqs_per_record_transactions.py +0 -0
  251. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_ssm_config.py +0 -0
  252. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_store_user_data.py +0 -0
  253. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  254. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_table_alter.py +0 -0
  255. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_transaction_class_wrapping.py +0 -0
  256. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_transaction_commit_and_ownership.py +0 -0
  257. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_transaction_edge_cases.py +0 -0
  258. {velocity_python-0.1.93 → velocity_python-0.1.94}/tests/test_where_clause_validation.py +0 -0
  259. {velocity_python-0.1.93 → velocity_python-0.1.94}/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.93
3
+ Version: 0.1.94
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.93"
7
+ version = "0.1.94"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.93"
1
+ __version__ = version = "0.1.94"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -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)