velocity-python 0.1.102__tar.gz → 0.1.104__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 (273) hide show
  1. {velocity_python-0.1.102 → velocity_python-0.1.104}/PKG-INFO +1 -1
  2. {velocity_python-0.1.102 → velocity_python-0.1.104}/pyproject.toml +1 -1
  3. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/mixins/__init__.py +4 -0
  5. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/mixins/data_service.py +61 -17
  6. velocity_python-0.1.104/src/velocity/aws/handlers/mixins/session_control.py +277 -0
  7. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/pdf.py +14 -2
  8. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/PKG-INFO +1 -1
  9. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  10. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_data_service_renumber.py +58 -11
  11. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_pdf.py +25 -0
  12. velocity_python-0.1.104/tests/test_session_control.py +255 -0
  13. {velocity_python-0.1.102 → velocity_python-0.1.104}/LICENSE +0 -0
  14. {velocity_python-0.1.102 → velocity_python-0.1.104}/README.md +0 -0
  15. {velocity_python-0.1.102 → velocity_python-0.1.104}/setup.cfg +0 -0
  16. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/__init__.py +0 -0
  17. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/amplify.py +0 -0
  18. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/amplify_build.py +0 -0
  19. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/__init__.py +0 -0
  20. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/backfill.py +0 -0
  21. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/indexing.py +0 -0
  22. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/references.py +0 -0
  23. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/service.py +0 -0
  24. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/assets/usage_index.py +0 -0
  25. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/blobs.py +0 -0
  26. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/__init__.py +0 -0
  27. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/config.py +0 -0
  28. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/operations.py +0 -0
  29. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/policies.py +0 -0
  30. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/reconcile.py +0 -0
  31. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/deploy/targets.py +0 -0
  32. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/dirty_pipeline.py +0 -0
  33. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/email.py +0 -0
  34. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/__init__.py +0 -0
  35. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/base_handler.py +0 -0
  36. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/context.py +0 -0
  37. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/context_factory.py +0 -0
  38. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/exceptions.py +0 -0
  39. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  40. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/masquerade.py +0 -0
  41. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/mixins/job_handler.py +0 -0
  42. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  43. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/perf.py +0 -0
  44. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/response.py +0 -0
  45. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  46. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/job_results.py +0 -0
  47. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/s3.py +0 -0
  48. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/sms.py +0 -0
  49. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/ssm_config.py +0 -0
  50. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/__init__.py +0 -0
  51. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
  52. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
  53. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  54. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
  55. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
  56. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_email.py +0 -0
  57. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  58. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_response.py +0 -0
  59. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/aws/tests/test_sms.py +0 -0
  60. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/__init__.py +0 -0
  61. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/__init__.py +0 -0
  62. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/async_support.py +0 -0
  63. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/column.py +0 -0
  64. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/database.py +0 -0
  65. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/decorators.py +0 -0
  66. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/engine.py +0 -0
  67. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/jsonproxy.py +0 -0
  68. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/result.py +0 -0
  69. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/row.py +0 -0
  70. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/sequence.py +0 -0
  71. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/table.py +0 -0
  72. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/transaction.py +0 -0
  73. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/core/view.py +0 -0
  74. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/exceptions.py +0 -0
  75. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/maintenance.py +0 -0
  76. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/migrations.py +0 -0
  77. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/__init__.py +0 -0
  78. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/base/__init__.py +0 -0
  79. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/base/initializer.py +0 -0
  80. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/base/operators.py +0 -0
  81. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/base/sql.py +0 -0
  82. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/base/types.py +0 -0
  83. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/mysql/__init__.py +0 -0
  84. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/mysql/operators.py +0 -0
  85. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/mysql/reserved.py +0 -0
  86. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/mysql/sql.py +0 -0
  87. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/mysql/types.py +0 -0
  88. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/postgres/__init__.py +0 -0
  89. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/postgres/operators.py +0 -0
  90. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/postgres/reserved.py +0 -0
  91. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/postgres/sql.py +0 -0
  92. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/postgres/types.py +0 -0
  93. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  94. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlite/operators.py +0 -0
  95. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  96. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlite/sql.py +0 -0
  97. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlite/types.py +0 -0
  98. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  99. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  100. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  101. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  102. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/sqlserver/types.py +0 -0
  103. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/servers/tablehelper.py +0 -0
  104. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/__init__.py +0 -0
  105. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/common_db_test.py +0 -0
  106. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/__init__.py +0 -0
  107. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/common.py +0 -0
  108. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/conftest.py +0 -0
  109. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_column.py +0 -0
  110. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  111. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_database.py +0 -0
  112. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  113. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  114. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  115. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_result.py +0 -0
  116. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_row.py +0 -0
  117. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  118. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  119. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  120. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  121. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  122. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_table.py +0 -0
  123. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  124. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  125. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/sql/__init__.py +0 -0
  126. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/sql/common.py +0 -0
  127. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  128. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  129. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  130. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_db_utils.py +0 -0
  131. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_postgres.py +0 -0
  132. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  133. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  134. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_result_caching.py +0 -0
  135. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  136. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  137. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  138. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  139. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_sql_builder.py +0 -0
  140. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_tablehelper.py +0 -0
  141. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/tests/test_view_helper.py +0 -0
  142. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/db/utils.py +0 -0
  143. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/logging.py +0 -0
  144. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/__init__.py +0 -0
  145. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/conv/__init__.py +0 -0
  146. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/conv/iconv.py +0 -0
  147. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/conv/oconv.py +0 -0
  148. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/db.py +0 -0
  149. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/export.py +0 -0
  150. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/format.py +0 -0
  151. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/mail.py +0 -0
  152. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/merge.py +0 -0
  153. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/__init__.py +0 -0
  154. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_db.py +0 -0
  155. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_fix.py +0 -0
  156. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_format.py +0 -0
  157. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_iconv.py +0 -0
  158. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_mail_send.py +0 -0
  159. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_merge.py +0 -0
  160. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_oconv.py +0 -0
  161. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_original_error.py +0 -0
  162. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tests/test_timer.py +0 -0
  163. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/timer.py +0 -0
  164. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/misc/tools.py +0 -0
  165. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/__init__.py +0 -0
  166. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/core/__init__.py +0 -0
  167. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/core/capabilities.py +0 -0
  168. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/core/store.py +0 -0
  169. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/core/where.py +0 -0
  170. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/exceptions.py +0 -0
  171. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/__init__.py +0 -0
  172. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/base/__init__.py +0 -0
  173. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
  174. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
  175. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
  176. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
  177. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
  178. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
  179. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
  180. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
  181. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/memory/__init__.py +0 -0
  182. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
  183. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
  184. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/nosql/servers/redis/__init__.py +0 -0
  185. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/__init__.py +0 -0
  186. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/authorizenet_adapter.py +0 -0
  187. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/authorizenet_mirror.py +0 -0
  188. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/base_adapter.py +0 -0
  189. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/braintree_adapter.py +0 -0
  190. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/braintree_mirror.py +0 -0
  191. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/charge_rules.py +0 -0
  192. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/stripe_adapter.py +0 -0
  193. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity/payment/stripe_mirror.py +0 -0
  194. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  195. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/entry_points.txt +0 -0
  196. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/requires.txt +0 -0
  197. {velocity_python-0.1.102 → velocity_python-0.1.104}/src/velocity_python.egg-info/top_level.txt +0 -0
  198. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_activity_tracking_aborted_tx.py +0 -0
  199. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_amplify_build.py +0 -0
  200. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_asset_indexing.py +0 -0
  201. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_asset_references.py +0 -0
  202. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_assets_service.py +0 -0
  203. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_async_support.py +0 -0
  204. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_batch_operations.py +0 -0
  205. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_blob_store.py +0 -0
  206. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_column_tx_arg.py +0 -0
  207. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_concurrency_safety.py +0 -0
  208. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_connection_pool.py +0 -0
  209. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_connection_resilience.py +0 -0
  210. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_context_job_descriptions.py +0 -0
  211. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_data_service_query_cap.py +0 -0
  212. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_data_service_save_as.py +0 -0
  213. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_db_credentials_ssm_cascade.py +0 -0
  214. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_db_maintenance.py +0 -0
  215. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_decorators.py +0 -0
  216. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_delete_hook_fk_blocked.py +0 -0
  217. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_dirty_pipeline_fast_path.py +0 -0
  218. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_email_processing.py +0 -0
  219. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_enqueue_send_failures.py +0 -0
  220. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_get_cognito_user_provider.py +0 -0
  221. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_http_handler_rollback.py +0 -0
  222. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_iconv_money_to_cents.py +0 -0
  223. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_identifier_injection_guard.py +0 -0
  224. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_job_handler.py +0 -0
  225. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_job_results.py +0 -0
  226. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_json_columns.py +0 -0
  227. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_jsonb_dict_adapter.py +0 -0
  228. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_lambda_handler.py +0 -0
  229. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_lambda_handler_auth.py +0 -0
  230. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_lambda_handler_masquerade.py +0 -0
  231. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_masquerade_grant.py +0 -0
  232. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_mixins_import.py +0 -0
  233. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_n_plus_one.py +0 -0
  234. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_dynamodb.py +0 -0
  235. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_memory.py +0 -0
  236. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_mongodb.py +0 -0
  237. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_redis.py +0 -0
  238. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_translation.py +0 -0
  239. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_nosql_where.py +0 -0
  240. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_observability.py +0 -0
  241. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_on_commit.py +0 -0
  242. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_authorizenet_adapter.py +0 -0
  243. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_braintree_adapter.py +0 -0
  244. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_braintree_mirror.py +0 -0
  245. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_mirror_empty_table.py +0 -0
  246. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_profile_sorting.py +0 -0
  247. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_payment_stripe_adapter.py +0 -0
  248. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_prepared_statements.py +0 -0
  249. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_psycopg3_upgrade.py +0 -0
  250. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_query_cache.py +0 -0
  251. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_query_missing_table_unlocked.py +0 -0
  252. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_response.py +0 -0
  253. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_restricted_direct_tables.py +0 -0
  254. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_retry_side_effect_guard.py +0 -0
  255. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_return_default_safety.py +0 -0
  256. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_row_batch_update.py +0 -0
  257. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_row_cache_staleness.py +0 -0
  258. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_schema_migrations.py +0 -0
  259. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_security_hardening.py +0 -0
  260. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_server_cursor.py +0 -0
  261. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_single_autocommit_safety.py +0 -0
  262. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_spreadsheet_functions.py +0 -0
  263. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_sqlite_backend.py +0 -0
  264. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_sqs_per_record_transactions.py +0 -0
  265. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_ssm_config.py +0 -0
  266. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_store_user_data.py +0 -0
  267. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  268. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_table_alter.py +0 -0
  269. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_transaction_class_wrapping.py +0 -0
  270. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_transaction_commit_and_ownership.py +0 -0
  271. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_transaction_edge_cases.py +0 -0
  272. {velocity_python-0.1.102 → velocity_python-0.1.104}/tests/test_where_clause_validation.py +0 -0
  273. {velocity_python-0.1.102 → velocity_python-0.1.104}/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.102
3
+ Version: 0.1.104
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.102"
7
+ version = "0.1.104"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.102"
1
+ __version__ = version = "0.1.104"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -6,15 +6,19 @@ This package provides mixins for common Lambda handler patterns:
6
6
  - ButtonHandler: Dynamic button action handler loading
7
7
  - DataServiceMixin: Generic CRUD operations for database-backed APIs
8
8
  - JobHandler: get-job-status / download-job-result / cancel-job for async jobs
9
+ - SessionControlMixin: opt-in single-active-login enforcement (register /
10
+ check / end session actions; pairs with velocity-react's SessionGate)
9
11
  """
10
12
 
11
13
  from .web_handler import WebHandler, ButtonHandler
12
14
  from .data_service import DataServiceMixin
13
15
  from .job_handler import JobHandler
16
+ from .session_control import SessionControlMixin
14
17
 
15
18
  __all__ = [
16
19
  "WebHandler",
17
20
  "ButtonHandler",
18
21
  "DataServiceMixin",
19
22
  "JobHandler",
23
+ "SessionControlMixin",
20
24
  ]
@@ -65,12 +65,59 @@ def _bump_sys_id_sequence(tx, table):
65
65
  )
66
66
 
67
67
 
68
+ def _sys_id_fk_references(tx, table):
69
+ """Foreign keys pointing AT this table's sys_id, as
70
+ (referencing_table, referencing_column) pairs. A multi-column FK yields
71
+ referencing_column None (callers treat it conservatively)."""
72
+ return tx.execute(
73
+ """select con.conrelid::regclass::text as referencing_table,
74
+ case when array_length(con.conkey, 1) = 1
75
+ then (select att.attname from pg_attribute att
76
+ where att.attrelid = con.conrelid
77
+ and att.attnum = con.conkey[1])
78
+ end as referencing_column
79
+ from pg_constraint con
80
+ join pg_class rel on rel.oid = con.confrelid
81
+ join pg_namespace nsp on nsp.oid = rel.relnamespace
82
+ where con.contype = 'f' and nsp.nspname = 'public'
83
+ and rel.relname = %s
84
+ and (select att.attname from pg_attribute att
85
+ where att.attrelid = con.confrelid
86
+ and att.attnum = con.confkey[1]) = 'sys_id'""",
87
+ (table,),
88
+ ).all()
89
+
90
+
91
+ def _count_fk_rows(tx, references, sys_id):
92
+ """Rows that actually reference ``sys_id`` through the given FK pairs.
93
+ Returns human-readable "table (n row(s))" descriptions, or raises for an
94
+ FK shape too odd to check safely (multi-column, unquotable names)."""
95
+ blocked = []
96
+ for ref in references:
97
+ ref_table = ref.get("referencing_table") or ""
98
+ ref_column = ref.get("referencing_column") or ""
99
+ if not (_IDENTIFIER_RE.match(ref_table) and _IDENTIFIER_RE.match(ref_column)):
100
+ raise AlertError(
101
+ f"Renumbering is not allowed: {ref_table or 'a table'} references "
102
+ f"this table through a foreign key this tool cannot inspect."
103
+ )
104
+ count = tx.execute(
105
+ f'select count(*) as n from "{ref_table}" where "{ref_column}" = %s',
106
+ (sys_id,),
107
+ ).scalar()
108
+ if count:
109
+ blocked.append(f"{ref_table} ({count} row(s))")
110
+ return blocked
111
+
112
+
68
113
  def renumber_row(tx, table, old_sys_id, new_sys_id, overwrite=False):
69
114
  """Assign a CHOSEN sys_id to an existing row (admin surgery).
70
115
 
71
116
  Rules (deliberately simple):
72
- - refused when any real foreign-key constraint references this table's
73
- sys_id (renumbering under FKs is a cascade problem for another day);
117
+ - a foreign-key CONSTRAINT on this table's sys_id is fine; what refuses
118
+ the change is actual referencing ROWS rows pointing at the old id
119
+ (reassigning references is not implemented), or, on overwrite, rows
120
+ pointing at the occupant that would be deleted;
74
121
  - when the target sys_id already exists: refused unless ``overwrite`` —
75
122
  which DELETES the occupying row first;
76
123
  - the table's sys_id sequence is bumped past MAX(sys_id) afterwards so
@@ -95,27 +142,24 @@ def renumber_row(tx, table, old_sys_id, new_sys_id, overwrite=False):
95
142
  if not tx.table(table).find(old_id):
96
143
  raise AlertError(f"Record {old_id} was not found in {table}.")
97
144
 
98
- referencing = tx.execute(
99
- """select con.conrelid::regclass::text as referencing_table
100
- from pg_constraint con
101
- join pg_class rel on rel.oid = con.confrelid
102
- join pg_namespace nsp on nsp.oid = rel.relnamespace
103
- where con.contype = 'f' and nsp.nspname = 'public'
104
- and rel.relname = %s
105
- limit 5""",
106
- (table,),
107
- ).all()
108
- if referencing:
109
- tables = ", ".join(sorted({r["referencing_table"] for r in referencing}))
145
+ references = _sys_id_fk_references(tx, table)
146
+ blocked = _count_fk_rows(tx, references, old_id)
147
+ if blocked:
110
148
  raise AlertError(
111
- f"Renumbering is not allowed: {tables} reference(s) {table} "
112
- "through a foreign key. (Denied for now reassigning "
113
- "references is not implemented.)"
149
+ f"Renumbering is not allowed: {', '.join(sorted(blocked))} still "
150
+ f"reference {table} {old_id}. (Reassigning references is not "
151
+ "implemented repoint or remove those rows first.)"
114
152
  )
115
153
 
116
154
  if tx.table(table).find(new_id):
117
155
  if not overwrite:
118
156
  return "exists"
157
+ occupied = _count_fk_rows(tx, references, new_id)
158
+ if occupied:
159
+ raise AlertError(
160
+ f"Cannot overwrite {table} {new_id}: {', '.join(sorted(occupied))} "
161
+ "still reference it."
162
+ )
119
163
  tx.execute(f'DELETE FROM "{table}" WHERE sys_id = %s', (new_id,))
120
164
 
121
165
  tx.execute(
@@ -0,0 +1,277 @@
1
+ """Single-active-session enforcement for handler-backed apps.
2
+
3
+ One live login per user: at sign-in the client registers a per-browser
4
+ session key; if another live session exists (and the user row's
5
+ ``allow_multiple_sessions`` switch is off) the register answers
6
+ ``conflict`` with the other session's ip / browser / times so the UI can
7
+ offer the choice — take over (ends the other session; its next heartbeat
8
+ signs it out) or back out of this sign-in. A periodic client heartbeat
9
+ keeps ``last_seen_at`` fresh and learns when it has been superseded;
10
+ sessions quiet past ``session_stale_minutes`` are expired server-side so a
11
+ crashed browser can't hold the account hostage.
12
+
13
+ Feature switch — OFF by default (opt-in per app):
14
+ - class attribute ``single_session_enforcement = True`` turns it on;
15
+ - the ``SingleSessionEnforcement`` config key overrides per environment
16
+ (same precedence pattern as ``QueryMaxRows``).
17
+ When off, every action answers as if the session were fine, so a deployed
18
+ frontend gate (velocity-react ``SessionGate``) becomes a no-op without a
19
+ frontend change.
20
+
21
+ Everything is scoped by the AUTHENTICATED session's email — the payload
22
+ only supplies the caller's own session key, so no action can read or end
23
+ another user's sessions. Sessions live in ``session_table`` (default
24
+ ``app_session``; auto-created on first write under unlocked auto-schema —
25
+ locked-schema apps declare it in their ensure_schema). The per-user
26
+ switch column is read from the handler's ``user_table``.
27
+
28
+ Usage::
29
+
30
+ class HttpEventHandler(SessionControlMixin, WebHandler, LambdaHandler):
31
+ user_table = "app_user"
32
+ single_session_enforcement = True
33
+
34
+ Promoted 2026-09-07 from dance.10sp.us (same lifecycle as tenspace's
35
+ app_auth: proven in the app, then centralized).
36
+ """
37
+
38
+ import datetime
39
+
40
+ from velocity.aws import ssm_config
41
+
42
+ _TRUE = {"1", "true", "yes", "on"}
43
+ _FALSE = {"0", "false", "no", "off"}
44
+
45
+
46
+ def _now():
47
+ return datetime.datetime.now(datetime.timezone.utc)
48
+
49
+
50
+ def _iso(value):
51
+ return value.isoformat() if hasattr(value, "isoformat") else (value or None)
52
+
53
+
54
+ def describe_browser(user_agent):
55
+ """Friendly 'Chrome on Windows' from a raw UA (best-effort, no deps)."""
56
+ ua = user_agent or ""
57
+ if "Edg/" in ua:
58
+ browser = "Edge"
59
+ elif "OPR/" in ua or "Opera" in ua:
60
+ browser = "Opera"
61
+ elif "Chrome/" in ua:
62
+ browser = "Chrome"
63
+ elif "Firefox/" in ua:
64
+ browser = "Firefox"
65
+ elif "Safari/" in ua:
66
+ browser = "Safari"
67
+ else:
68
+ browser = "Unknown browser"
69
+ # order matters: Android UAs contain "Linux", iPads report "Mac OS X"
70
+ if "Windows" in ua:
71
+ os_name = "Windows"
72
+ elif "Android" in ua:
73
+ os_name = "Android"
74
+ elif "iPhone" in ua or "iPad" in ua:
75
+ os_name = "iPhone/iPad"
76
+ elif "Mac OS X" in ua:
77
+ os_name = "Mac"
78
+ elif "Linux" in ua:
79
+ os_name = "Linux"
80
+ else:
81
+ os_name = ""
82
+ return f"{browser} on {os_name}" if os_name else browser
83
+
84
+
85
+ def client_info(aws_event):
86
+ """(ip, user_agent) from the API Gateway event — server-derived, never
87
+ trusted from the payload."""
88
+ identity = (aws_event or {}).get("requestContext", {}).get("identity", {})
89
+ headers = (aws_event or {}).get("headers") or {}
90
+ ip = identity.get("sourceIp") or ""
91
+ user_agent = headers.get("User-Agent") or headers.get("user-agent") or ""
92
+ return ip, user_agent
93
+
94
+
95
+ def _describe(row):
96
+ return {
97
+ "ip": row.get("ip") or "",
98
+ "browser": row.get("browser") or row.get("user_agent") or "",
99
+ "started_at": _iso(row.get("started_at")),
100
+ "last_seen_at": _iso(row.get("last_seen_at")),
101
+ }
102
+
103
+
104
+ class SessionControlMixin:
105
+ """register-session / check-session / end-session for the handler.
106
+
107
+ Add the three action names to any non-staff/authenticated-only allowlist
108
+ the handler enforces — every signed-in user needs them.
109
+ """
110
+
111
+ # The app-level switch (see module docstring). The
112
+ # ``SingleSessionEnforcement`` config key overrides per environment.
113
+ single_session_enforcement = False
114
+
115
+ session_table = "app_session"
116
+ session_stale_minutes = 10
117
+ # user-table column holding the per-user opt-out of the single limit
118
+ session_multi_column = "allow_multiple_sessions"
119
+
120
+ # ── switch resolution ────────────────────────────────────────────────
121
+ def _session_enforcement_enabled(self):
122
+ raw = None
123
+ try:
124
+ raw = ssm_config.getenv("SingleSessionEnforcement", None)
125
+ except Exception:
126
+ raw = None
127
+ if raw is not None:
128
+ lowered = str(raw).strip().lower()
129
+ if lowered in _TRUE:
130
+ return True
131
+ if lowered in _FALSE:
132
+ return False
133
+ return bool(self.single_session_enforcement)
134
+
135
+ def _session_user_table(self):
136
+ return getattr(self, "user_table", None) or "app_user"
137
+
138
+ # ── shared internals ─────────────────────────────────────────────────
139
+ def _session_email(self, context):
140
+ return ((context.session() or {}).get("email_address") or "").strip().lower()
141
+
142
+ def _live_sessions(self, tx, email, now):
143
+ """Live sessions for the user, expiring quiet ones on the way."""
144
+ sessions = tx.table(self.session_table)
145
+ stale_after = datetime.timedelta(minutes=self.session_stale_minutes)
146
+ live = []
147
+ for row in sessions.select(where={"email_address": email, "ended_at": None}):
148
+ seen = row.get("last_seen_at") or row.get("started_at")
149
+ if seen and (now - seen) > stale_after:
150
+ sessions.update(
151
+ {"ended_at": now, "ended_by": "expired (no heartbeat)"},
152
+ where={"sys_id": row["sys_id"]},
153
+ )
154
+ continue
155
+ live.append(row)
156
+ return live
157
+
158
+ def _allow_multiple(self, tx, email):
159
+ row = next(
160
+ iter(
161
+ tx.table(self._session_user_table()).select(
162
+ where={"email_address": email}, qty=1
163
+ )
164
+ ),
165
+ None,
166
+ )
167
+ return bool(row and row.get(self.session_multi_column))
168
+
169
+ # ── actions ──────────────────────────────────────────────────────────
170
+ def OnActionRegisterSession(self, tx, context):
171
+ """Claim (or refresh) the caller's session; 'conflict' when someone
172
+ else is signed in, the user's multi-session switch is off, and this
173
+ isn't a takeover."""
174
+ payload = context.payload() or {}
175
+ email = self._session_email(context)
176
+ session_key = payload.get("session_key")
177
+ if not (email and session_key) or not self._session_enforcement_enabled():
178
+ context.response().set_body({"status": "active"})
179
+ return
180
+ ip, user_agent = client_info(self.aws_event)
181
+ now = _now()
182
+ sessions = tx.table(self.session_table)
183
+
184
+ mine = next(
185
+ iter(sessions.select(where={"session_key": session_key}, qty=1)), None
186
+ )
187
+ others = [
188
+ r
189
+ for r in self._live_sessions(tx, email, now)
190
+ if r.get("session_key") != session_key
191
+ ]
192
+ takeover = bool(payload.get("takeover"))
193
+
194
+ if others and not takeover and not self._allow_multiple(tx, email):
195
+ # my previously-live row (a page refresh) doesn't excuse the
196
+ # conflict: only an ACTIVE claim survives, and mine may have
197
+ # been superseded
198
+ if not (mine and mine.get("ended_at") is None):
199
+ context.response().set_body(
200
+ {"status": "conflict", "sessions": [_describe(r) for r in others]}
201
+ )
202
+ return
203
+
204
+ if takeover:
205
+ for row in others:
206
+ sessions.update(
207
+ {
208
+ "ended_at": now,
209
+ "ended_by": (
210
+ f"signed out by a new sign-in from {ip or 'unknown ip'}"
211
+ ),
212
+ },
213
+ where={"sys_id": row["sys_id"]},
214
+ )
215
+
216
+ fields = {
217
+ "email_address": email,
218
+ "ip": ip,
219
+ "user_agent": user_agent,
220
+ "browser": describe_browser(user_agent),
221
+ "last_seen_at": now,
222
+ }
223
+ if mine and mine.get("ended_at") is None:
224
+ # same login continuing (page refresh) — keep started_at
225
+ sessions.update(fields, where={"sys_id": mine["sys_id"]})
226
+ else:
227
+ # brand-new sign-in, or reclaiming a key that was previously ended
228
+ fields.update({"started_at": now, "ended_at": None, "ended_by": None})
229
+ sessions.upsert(fields, {"session_key": session_key})
230
+ context.response().set_body({"status": "active"})
231
+
232
+ def OnActionCheckSession(self, tx, context):
233
+ """Heartbeat: refresh last_seen_at, or report who superseded us."""
234
+ payload = context.payload() or {}
235
+ email = self._session_email(context)
236
+ session_key = payload.get("session_key")
237
+ if not (email and session_key) or not self._session_enforcement_enabled():
238
+ context.response().set_body({"status": "active"})
239
+ return
240
+ now = _now()
241
+ sessions = tx.table(self.session_table)
242
+ mine = next(
243
+ iter(
244
+ sessions.select(
245
+ where={"session_key": session_key, "email_address": email}, qty=1
246
+ )
247
+ ),
248
+ None,
249
+ )
250
+ if mine and mine.get("ended_at") is None:
251
+ sessions.update({"last_seen_at": now}, where={"sys_id": mine["sys_id"]})
252
+ context.response().set_body({"status": "active"})
253
+ return
254
+ live = self._live_sessions(tx, email, now)
255
+ context.response().set_body(
256
+ {
257
+ "status": "superseded",
258
+ "reason": (mine or {}).get("ended_by") or "session ended",
259
+ "by": _describe(live[0]) if live else None,
260
+ }
261
+ )
262
+
263
+ def OnActionEndSession(self, tx, context):
264
+ """Clean sign-out: release the caller's own session claim."""
265
+ payload = context.payload() or {}
266
+ email = self._session_email(context)
267
+ session_key = payload.get("session_key")
268
+ if email and session_key and self._session_enforcement_enabled():
269
+ tx.table(self.session_table).update(
270
+ {"ended_at": _now(), "ended_by": "signed out"},
271
+ where={
272
+ "session_key": session_key,
273
+ "email_address": email,
274
+ "ended_at": None,
275
+ },
276
+ )
277
+ context.response().set_body({"status": "ended"})
@@ -161,12 +161,23 @@ def _prepend_env_path(name: str, *paths: str) -> None:
161
161
  os.environ[name] = os.pathsep.join(merged)
162
162
 
163
163
 
164
+ def _configure_layer_fonts() -> None:
165
+ """Point fontconfig at a Lambda layer's /opt/fonts when nothing else has.
166
+
167
+ Must run BEFORE the first render in the process: with no usable
168
+ fontconfig config Pango gets a NULL font and the whole runtime dies with
169
+ a segmentation fault, not a Python error (found live 2026-09-07 on an
170
+ AL2023/python3.12 stack whose functions did not set FONTCONFIG_PATH).
171
+ Harmless off-Lambda — the directory simply doesn't exist."""
172
+ if os.path.isdir("/opt/fonts") and not os.environ.get("FONTCONFIG_PATH"):
173
+ os.environ["FONTCONFIG_PATH"] = "/opt/fonts"
174
+
175
+
164
176
  def _configure_wkhtml_runtime() -> None:
165
177
  # Lambda layers put the binary in /opt/bin and its shared libs in /opt/lib.
166
178
  _prepend_env_path("PATH", "/opt/bin")
167
179
  _prepend_env_path("LD_LIBRARY_PATH", "/opt/lib")
168
- if os.path.isdir("/opt/fonts") and not os.environ.get("FONTCONFIG_PATH"):
169
- os.environ["FONTCONFIG_PATH"] = "/opt/fonts"
180
+ _configure_layer_fonts()
170
181
 
171
182
 
172
183
  def _find_wkhtmltopdf() -> str | None:
@@ -351,6 +362,7 @@ class WeasyPrintRenderer(BasePdfRenderer):
351
362
  "that carries its Pango/Cairo libraries and set LD_LIBRARY_PATH=/opt/lib, "
352
363
  f"FONTCONFIG_PATH=/opt/fonts). Root cause: {_weasyprint_import_error!r}"
353
364
  )
365
+ _configure_layer_fonts()
354
366
  css_text = page_css(page)
355
367
  if extra_css:
356
368
  css_text += "\n" + extra_css
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.102
3
+ Version: 0.1.104
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
@@ -38,6 +38,7 @@ src/velocity/aws/handlers/sqs_handler.py
38
38
  src/velocity/aws/handlers/mixins/__init__.py
39
39
  src/velocity/aws/handlers/mixins/data_service.py
40
40
  src/velocity/aws/handlers/mixins/job_handler.py
41
+ src/velocity/aws/handlers/mixins/session_control.py
41
42
  src/velocity/aws/handlers/mixins/web_handler.py
42
43
  src/velocity/aws/tests/__init__.py
43
44
  src/velocity/aws/tests/test_amplify_build_policy_size.py
@@ -254,6 +255,7 @@ tests/test_row_cache_staleness.py
254
255
  tests/test_schema_migrations.py
255
256
  tests/test_security_hardening.py
256
257
  tests/test_server_cursor.py
258
+ tests/test_session_control.py
257
259
  tests/test_single_autocommit_safety.py
258
260
  tests/test_spreadsheet_functions.py
259
261
  tests/test_sqlite_backend.py
@@ -1,5 +1,6 @@
1
- """renumber_row: chosen-sys_id assignment with FK denial, occupied-target
2
- confirmation, overwrite semantics, and the sequence bump."""
1
+ """renumber_row: chosen-sys_id assignment with row-level FK checking (a
2
+ constraint alone no longer denies actual referencing ROWS do), occupied-
3
+ target confirmation, overwrite semantics, and the sequence bump."""
3
4
 
4
5
  import pytest
5
6
  from unittest.mock import MagicMock
@@ -9,9 +10,13 @@ from velocity.aws.handlers.mixins.data_service import renumber_row
9
10
 
10
11
 
11
12
  class FakeTx:
12
- def __init__(self, existing_ids=(5,), fk_tables=()):
13
+ def __init__(self, existing_ids=(5,), fk=(), refs=None):
14
+ """``fk``: (referencing_table, referencing_column) pairs — the FK
15
+ constraints on this table's sys_id. ``refs``: {(table, column):
16
+ {sys_id: row_count}} — the rows actually referencing each id."""
13
17
  self.existing = set(existing_ids)
14
- self.fk_tables = list(fk_tables)
18
+ self.fk = list(fk)
19
+ self.refs = refs or {}
15
20
  self.executed = []
16
21
  outer = self
17
22
 
@@ -29,8 +34,15 @@ class FakeTx:
29
34
  result = MagicMock()
30
35
  if "pg_constraint" in sql:
31
36
  result.all.return_value = [
32
- {"referencing_table": t} for t in self.fk_tables
37
+ {"referencing_table": t, "referencing_column": c} for t, c in self.fk
33
38
  ]
39
+ elif sql.strip().lower().startswith("select count(*)"):
40
+ for (t, c), counts in self.refs.items():
41
+ if f'"{t}"' in sql and f'"{c}"' in sql:
42
+ result.scalar.return_value = counts.get(params[0], 0)
43
+ break
44
+ else:
45
+ result.scalar.return_value = 0
34
46
  else:
35
47
  result.all.return_value = []
36
48
  if sql.strip().upper().startswith("DELETE"):
@@ -54,6 +66,28 @@ def test_simple_renumber_updates_and_bumps_sequence():
54
66
  assert 42 in tx.existing and 5 not in tx.existing
55
67
 
56
68
 
69
+ def test_fk_constraint_without_referencing_rows_is_allowed():
70
+ # Paul's studios case (2026-09-05): a legacy event.studio_id FK exists,
71
+ # but no event row points at this studio — the renumber must proceed.
72
+ tx = FakeTx(existing_ids=(5,), fk=(("event", "studio_id"),))
73
+ assert renumber_row(tx, "studio", 5, 42) == "renumbered"
74
+ assert any(s.startswith("select count(*)") for s in sqls(tx))
75
+ assert 42 in tx.existing
76
+
77
+
78
+ def test_actual_referencing_rows_deny_with_counts():
79
+ tx = FakeTx(
80
+ existing_ids=(5,),
81
+ fk=(("event", "studio_id"),),
82
+ refs={("event", "studio_id"): {5: 3}},
83
+ )
84
+ with pytest.raises(AlertError) as err:
85
+ renumber_row(tx, "studio", 5, 42)
86
+ assert "event (3 row(s))" in str(err.value)
87
+ assert "still reference studio 5" in str(err.value)
88
+ assert not any(s.startswith(("DELETE", "UPDATE")) for s in sqls(tx))
89
+
90
+
57
91
  def test_occupied_target_reports_exists_without_touching_data():
58
92
  tx = FakeTx(existing_ids=(5, 42))
59
93
  assert renumber_row(tx, "studio", 5, 42) == "exists"
@@ -61,19 +95,32 @@ def test_occupied_target_reports_exists_without_touching_data():
61
95
 
62
96
 
63
97
  def test_overwrite_deletes_the_occupant_then_renumbers():
64
- tx = FakeTx(existing_ids=(5, 42))
98
+ tx = FakeTx(existing_ids=(5, 42), fk=(("event", "studio_id"),))
65
99
  assert renumber_row(tx, "studio", 5, 42, overwrite=True) == "renumbered"
66
100
  ordered = [s.split()[0] for s in sqls(tx) if s.split()[0] in ("DELETE", "UPDATE")]
67
101
  assert ordered == ["DELETE", "UPDATE"]
68
102
  assert tx.existing == {42}
69
103
 
70
104
 
71
- def test_foreign_key_reference_denies_the_change():
72
- tx = FakeTx(existing_ids=(5,), fk_tables=("event_link_student",))
105
+ def test_overwrite_refused_when_the_occupant_is_referenced():
106
+ tx = FakeTx(
107
+ existing_ids=(5, 42),
108
+ fk=(("event", "studio_id"),),
109
+ refs={("event", "studio_id"): {42: 2}},
110
+ )
73
111
  with pytest.raises(AlertError) as err:
74
- renumber_row(tx, "people", 5, 42)
75
- assert "event_link_student" in str(err.value)
76
- assert not any(s.startswith(("DELETE", "UPDATE")) for s in sqls(tx))
112
+ renumber_row(tx, "studio", 5, 42, overwrite=True)
113
+ assert "Cannot overwrite studio 42" in str(err.value)
114
+ assert "event (2 row(s))" in str(err.value)
115
+ assert 42 in tx.existing # occupant untouched
116
+
117
+
118
+ def test_uninspectable_fk_shape_stays_conservatively_denied():
119
+ # Multi-column FK surfaces referencing_column None — deny rather than guess.
120
+ tx = FakeTx(existing_ids=(5,), fk=(("event_link", None),))
121
+ with pytest.raises(AlertError) as err:
122
+ renumber_row(tx, "studio", 5, 42)
123
+ assert "cannot inspect" in str(err.value)
77
124
 
78
125
 
79
126
  @pytest.mark.parametrize(
@@ -382,3 +382,28 @@ class TestWeasyPrintReal:
382
382
  def test_real_render_produces_a_pdf(self):
383
383
  out = html_to_pdf("<h1>hello</h1>", engine="weasyprint", margin="0in")
384
384
  assert out.startswith(b"%PDF-")
385
+
386
+
387
+ class TestLayerFontConfiguration:
388
+ """_configure_layer_fonts: points fontconfig at /opt/fonts before a render
389
+ so Pango can never come up font-less on Lambda (a NULL font segfaults the
390
+ runtime — found live 2026-09-07)."""
391
+
392
+ def test_sets_fontconfig_path_when_layer_fonts_exist(self, monkeypatch):
393
+ monkeypatch.delenv("FONTCONFIG_PATH", raising=False)
394
+ monkeypatch.setattr(
395
+ pdfmod.os.path, "isdir", lambda p: p == "/opt/fonts"
396
+ )
397
+ pdfmod._configure_layer_fonts()
398
+ assert pdfmod.os.environ["FONTCONFIG_PATH"] == "/opt/fonts"
399
+
400
+ def test_never_overrides_an_explicit_setting_or_missing_dir(self, monkeypatch):
401
+ monkeypatch.setenv("FONTCONFIG_PATH", "/custom/fonts")
402
+ monkeypatch.setattr(pdfmod.os.path, "isdir", lambda p: True)
403
+ pdfmod._configure_layer_fonts()
404
+ assert pdfmod.os.environ["FONTCONFIG_PATH"] == "/custom/fonts"
405
+
406
+ monkeypatch.delenv("FONTCONFIG_PATH", raising=False)
407
+ monkeypatch.setattr(pdfmod.os.path, "isdir", lambda p: False)
408
+ pdfmod._configure_layer_fonts()
409
+ assert "FONTCONFIG_PATH" not in pdfmod.os.environ