velocity-python 0.0.203__tar.gz → 0.0.205__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 (149) hide show
  1. {velocity_python-0.0.203 → velocity_python-0.0.205}/PKG-INFO +1 -1
  2. {velocity_python-0.0.203 → velocity_python-0.0.205}/pyproject.toml +1 -1
  3. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/base_handler.py +50 -6
  5. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/engine.py +28 -13
  6. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/transaction.py +15 -0
  7. velocity_python-0.0.205/src/velocity/db/core/view.py +158 -0
  8. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/base/sql.py +37 -0
  9. velocity_python-0.0.205/src/velocity/db/tests/test_view_helper.py +97 -0
  10. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity_python.egg-info/PKG-INFO +1 -1
  11. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity_python.egg-info/SOURCES.txt +2 -0
  12. {velocity_python-0.0.203 → velocity_python-0.0.205}/LICENSE +0 -0
  13. {velocity_python-0.0.203 → velocity_python-0.0.205}/README.md +0 -0
  14. {velocity_python-0.0.203 → velocity_python-0.0.205}/setup.cfg +0 -0
  15. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/__init__.py +0 -0
  16. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/invoices.py +0 -0
  17. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/orders.py +0 -0
  18. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/payments.py +0 -0
  19. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/purchase_orders.py +0 -0
  20. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/tests/__init__.py +0 -0
  21. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/tests/test_email_processing.py +0 -0
  22. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/tests/test_payment_profile_sorting.py +0 -0
  23. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/app/tests/test_spreadsheet_functions.py +0 -0
  24. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/__init__.py +0 -0
  25. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/amplify.py +0 -0
  26. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/__init__.py +0 -0
  27. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/context.py +0 -0
  28. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/context_factory.py +0 -0
  29. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/exceptions.py +0 -0
  30. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  31. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  32. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  33. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
  34. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/perf.py +0 -0
  35. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/response.py +0 -0
  36. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  37. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/tests/__init__.py +0 -0
  38. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  39. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/aws/tests/test_response.py +0 -0
  40. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/__init__.py +0 -0
  41. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/__init__.py +0 -0
  42. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/column.py +0 -0
  43. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/database.py +0 -0
  44. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/decorators.py +0 -0
  45. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/result.py +0 -0
  46. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/row.py +0 -0
  47. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/sequence.py +0 -0
  48. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/core/table.py +0 -0
  49. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/exceptions.py +0 -0
  50. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/__init__.py +0 -0
  51. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/base/__init__.py +0 -0
  52. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/base/initializer.py +0 -0
  53. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/base/operators.py +0 -0
  54. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/base/types.py +0 -0
  55. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/mysql/__init__.py +0 -0
  56. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/mysql/operators.py +0 -0
  57. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/mysql/reserved.py +0 -0
  58. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/mysql/sql.py +0 -0
  59. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/mysql/types.py +0 -0
  60. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/postgres/__init__.py +0 -0
  61. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/postgres/operators.py +0 -0
  62. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/postgres/reserved.py +0 -0
  63. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/postgres/sql.py +0 -0
  64. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/postgres/types.py +0 -0
  65. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  66. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlite/operators.py +0 -0
  67. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  68. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlite/sql.py +0 -0
  69. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlite/types.py +0 -0
  70. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  71. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  72. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  73. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  74. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/sqlserver/types.py +0 -0
  75. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/servers/tablehelper.py +0 -0
  76. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/__init__.py +0 -0
  77. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/common_db_test.py +0 -0
  78. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/__init__.py +0 -0
  79. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/common.py +0 -0
  80. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_column.py +0 -0
  81. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  82. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_database.py +0 -0
  83. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  84. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  85. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  86. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_result.py +0 -0
  87. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_row.py +0 -0
  88. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  89. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  90. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  91. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  92. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  93. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_table.py +0 -0
  94. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  95. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  96. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/sql/__init__.py +0 -0
  97. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/sql/common.py +0 -0
  98. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  99. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  100. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  101. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_db_utils.py +0 -0
  102. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_postgres.py +0 -0
  103. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  104. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  105. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_result_caching.py +0 -0
  106. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  107. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  108. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  109. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  110. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_sql_builder.py +0 -0
  111. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/tests/test_tablehelper.py +0 -0
  112. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/db/utils.py +0 -0
  113. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/logging.py +0 -0
  114. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/__init__.py +0 -0
  115. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/conv/__init__.py +0 -0
  116. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/conv/iconv.py +0 -0
  117. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/conv/oconv.py +0 -0
  118. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/db.py +0 -0
  119. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/export.py +0 -0
  120. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/format.py +0 -0
  121. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/mail.py +0 -0
  122. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/merge.py +0 -0
  123. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/__init__.py +0 -0
  124. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_db.py +0 -0
  125. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_fix.py +0 -0
  126. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_format.py +0 -0
  127. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_iconv.py +0 -0
  128. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_merge.py +0 -0
  129. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_oconv.py +0 -0
  130. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_original_error.py +0 -0
  131. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tests/test_timer.py +0 -0
  132. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/timer.py +0 -0
  133. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/misc/tools.py +0 -0
  134. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/payment/__init__.py +0 -0
  135. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/payment/base_adapter.py +0 -0
  136. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/payment/braintree_adapter.py +0 -0
  137. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/payment/router.py +0 -0
  138. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity/payment/stripe_adapter.py +0 -0
  139. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  140. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity_python.egg-info/requires.txt +0 -0
  141. {velocity_python-0.0.203 → velocity_python-0.0.205}/src/velocity_python.egg-info/top_level.txt +0 -0
  142. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_decorators.py +0 -0
  143. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_iconv_money_to_cents.py +0 -0
  144. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_lambda_handler.py +0 -0
  145. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_lambda_handler_auth.py +0 -0
  146. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_mixins_import.py +0 -0
  147. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  148. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_table_alter.py +0 -0
  149. {velocity_python-0.0.203 → velocity_python-0.0.205}/tests/test_where_clause_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.203
3
+ Version: 0.0.205
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.0.203"
7
+ version = "0.0.205"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.203"
1
+ __version__ = version = "0.0.205"
2
2
 
3
3
  from . import aws
4
4
  from . import db
@@ -221,17 +221,61 @@ class BaseHandler:
221
221
  local_context: The context object
222
222
  exception: The exception that occurred
223
223
  """
224
+ # Always log the original exception so it isn't lost if the error-path
225
+ # (e.g. job-status updates) fails due to a transient DB disconnect.
226
+ logger.exception(
227
+ "Unhandled exception during action execution",
228
+ extra={
229
+ "handler": self.__class__.__name__,
230
+ "action": getattr(local_context, "action", lambda: None)(),
231
+ "tx_present": tx is not None,
232
+ },
233
+ )
234
+
224
235
  if hasattr(self, "onError"):
225
- self.onError(
226
- tx,
227
- local_context,
228
- exc=exception.__class__.__name__,
229
- tb=traceback.format_exc(),
230
- )
236
+ try:
237
+ self.onError(
238
+ tx,
239
+ local_context,
240
+ exc=exception.__class__.__name__,
241
+ tb=traceback.format_exc(),
242
+ )
243
+ except Exception as on_error_exc:
244
+ if self._is_transient_db_disconnect(on_error_exc):
245
+ logger.warning(
246
+ "onError failed due to transient DB disconnect; continuing",
247
+ exc_info=True,
248
+ extra={
249
+ "handler": self.__class__.__name__,
250
+ "action": getattr(local_context, "action", lambda: None)(),
251
+ "original_exc": exception.__class__.__name__,
252
+ "onerror_exc": on_error_exc.__class__.__name__,
253
+ },
254
+ )
255
+ return
256
+ raise
231
257
  else:
232
258
  # Re-raise if no error handler is defined
233
259
  raise exception
234
260
 
261
+ @staticmethod
262
+ def _is_transient_db_disconnect(exc: Exception) -> bool:
263
+ """Return True if an exception looks like a transient DB disconnect.
264
+
265
+ This is intentionally message-based so it works even if the originating
266
+ exception type comes from psycopg2 / SQLAlchemy / wrapped Velocity errors.
267
+ """
268
+ try:
269
+ from velocity.db import exceptions as db_exceptions
270
+
271
+ if isinstance(exc, db_exceptions.DbConnectionError):
272
+ return True
273
+ except Exception:
274
+ # Avoid import-time coupling; fall back to message matching.
275
+ pass
276
+
277
+ return False
278
+
235
279
  def log(
236
280
  self, tx, message: str, function: Optional[str] = None, level: str = "info"
237
281
  ):
@@ -1,5 +1,6 @@
1
1
  import inspect
2
2
  import re
3
+ import time
3
4
  from contextlib import contextmanager
4
5
  from functools import wraps
5
6
  from velocity.db import exceptions
@@ -175,6 +176,7 @@ class Engine:
175
176
  else:
176
177
  retry_count = 0
177
178
  lock_timeout_count = 0
179
+ connection_retry_count = 0
178
180
  while True:
179
181
  try:
180
182
  return function(*args, **kwds)
@@ -182,13 +184,37 @@ class Engine:
182
184
  retry_count += 1
183
185
  if retry_count > self.MAX_RETRIES:
184
186
  raise
187
+ # Back off a bit to reduce contention on hot DDL.
188
+ time.sleep(min(2.0, 0.05 * (2**min(retry_count, 6))))
185
189
  _tx.rollback()
186
190
  except exceptions.DbLockTimeoutError:
187
191
  lock_timeout_count += 1
188
192
  if lock_timeout_count > self.MAX_RETRIES:
189
193
  raise
194
+ time.sleep(min(2.0, 0.05 * (2**min(lock_timeout_count, 6))))
190
195
  _tx.rollback()
191
196
  continue
197
+ except exceptions.DbConnectionError as e:
198
+ # Transient disconnects can happen during maintenance / restarts.
199
+ # Retrying the entire top-level function is the safest option.
200
+ msg = str(e).strip().lower()
201
+ if not getattr(
202
+ self.sql, "is_transient_connection_error_message", lambda _m: False
203
+ )(msg):
204
+ raise
205
+
206
+ connection_retry_count += 1
207
+ if connection_retry_count > 6:
208
+ raise
209
+
210
+ # Force a reconnect on the next attempt.
211
+ try:
212
+ _tx.close()
213
+ except Exception:
214
+ _tx.connection = None
215
+
216
+ time.sleep(min(2.0, 0.1 * (2**min(connection_retry_count, 5))))
217
+ continue
192
218
  except Exception:
193
219
  raise
194
220
  finally:
@@ -440,19 +466,8 @@ class Engine:
440
466
  raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
441
467
  if re.findall(r"already exists", msg, re.M):
442
468
  raise exceptions.DbObjectExistsError(enhanced_message) from exception
443
- if re.findall(r"server closed the connection unexpectedly", msg, re.M):
444
- raise exceptions.DbConnectionError(enhanced_message) from exception
445
- if re.findall(r"no connection to the server", msg, re.M):
446
- raise exceptions.DbConnectionError(enhanced_message) from exception
447
- if re.findall(r"connection timed out", msg, re.M):
448
- raise exceptions.DbConnectionError(enhanced_message) from exception
449
- if re.findall(r"could not connect to server", msg, re.M):
450
- raise exceptions.DbConnectionError(enhanced_message) from exception
451
- if re.findall(r"cannot connect to server", msg, re.M):
452
- raise exceptions.DbConnectionError(enhanced_message) from exception
453
- if re.findall(r"connection already closed", msg, re.M):
454
- raise exceptions.DbConnectionError(enhanced_message) from exception
455
- if re.findall(r"cursor already closed", msg, re.M):
469
+ # Dialect-specific connection error message classification (fallback when no/unknown code).
470
+ if getattr(self.sql, "is_connection_error_message", lambda _m: False)(msg):
456
471
  raise exceptions.DbConnectionError(enhanced_message) from exception
457
472
  if "no such table:" in msg:
458
473
  raise exceptions.DbTableMissingError(enhanced_message) from exception
@@ -2,6 +2,7 @@ import traceback
2
2
 
3
3
  from velocity.db.core.row import Row
4
4
  from velocity.db.core.table import Table
5
+ from velocity.db.core.view import View
5
6
  from velocity.db.core.result import Result
6
7
  from velocity.db.core.column import Column
7
8
  from velocity.db.core.database import Database
@@ -47,6 +48,16 @@ class Transaction:
47
48
  """
48
49
  Retrieves a database cursor, opening a connection if necessary.
49
50
  """
51
+ # Lazily connect, and also recover if the driver reports a closed connection.
52
+ # (psycopg2 uses `connection.closed != 0` to indicate closed.)
53
+ if self.connection is not None:
54
+ try:
55
+ if getattr(self.connection, "closed", 0):
56
+ self.connection = None
57
+ except Exception:
58
+ # If the driver object is in a bad state, force a reconnect.
59
+ self.connection = None
60
+
50
61
  if not self.connection:
51
62
  self.connection = self.engine.connect()
52
63
  if debug:
@@ -157,6 +168,10 @@ class Transaction:
157
168
  """
158
169
  return Table(self, tablename)
159
170
 
171
+ def view(self, viewname, schema=None):
172
+ """Returns a View helper for the given view name."""
173
+ return View(self, viewname, schema=schema)
174
+
160
175
  def sequence(self, name):
161
176
  """
162
177
  Returns a Sequence object for the given sequence name.
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from velocity.db import exceptions
6
+
7
+
8
+ def _quote_ident(identifier: str) -> str:
9
+ return '"' + str(identifier).replace('"', '""') + '"'
10
+
11
+
12
+ def _grantee_sql(grantee: str) -> str:
13
+ normalized = str(grantee).strip()
14
+ if not normalized:
15
+ raise ValueError("grantee cannot be empty")
16
+
17
+ keyword = normalized.upper()
18
+ if keyword in {"PUBLIC", "CURRENT_USER", "SESSION_USER"}:
19
+ return keyword
20
+
21
+ return _quote_ident(normalized)
22
+
23
+
24
+ @dataclass
25
+ class Grant:
26
+ privilege: str
27
+ grantee: str
28
+
29
+
30
+ class View:
31
+ """PostgreSQL view helper.
32
+
33
+ This is intentionally lightweight and uses catalog queries directly rather than
34
+ embedding view DDL in the dialect.
35
+
36
+ Primary goals:
37
+ - Create views in an idempotent way (CREATE OR REPLACE).
38
+ - If the view already exists, "enhance" it by applying missing grants.
39
+
40
+ Notes:
41
+ - For updating the view definition, pass replace_existing=True.
42
+ - Privilege checks use has_table_privilege().
43
+ """
44
+
45
+ def __init__(self, tx, name: str, schema: str | None = None):
46
+ self.tx = tx
47
+ self.name = str(name).lower()
48
+ self.schema = (schema or getattr(tx.engine.sql, "default_schema", None) or "public").lower()
49
+
50
+ @property
51
+ def qualified_name(self) -> str:
52
+ return f"{_quote_ident(self.schema)}.{_quote_ident(self.name)}"
53
+
54
+ def exists(self) -> bool:
55
+ result = self.tx.execute(
56
+ """
57
+ SELECT 1
58
+ FROM information_schema.views
59
+ WHERE table_schema = %s AND table_name = %s
60
+ LIMIT 1
61
+ """,
62
+ [self.schema, self.name],
63
+ )
64
+ try:
65
+ rows = result.as_dict().all()
66
+ except Exception:
67
+ rows = []
68
+ return bool(rows)
69
+
70
+ def create_or_replace(self, select_sql: str) -> None:
71
+ if self.tx.engine.schema_locked:
72
+ raise exceptions.DbSchemaLockedError(
73
+ f"Cannot create/replace view {self.schema}.{self.name}: schema is locked"
74
+ )
75
+
76
+ sql = (select_sql or "").strip().rstrip(";")
77
+ if not sql:
78
+ raise ValueError("select_sql cannot be empty")
79
+
80
+ # If caller already provided CREATE VIEW, execute as-is.
81
+ lowered = sql.lower().lstrip()
82
+ if lowered.startswith("create"):
83
+ self.tx.execute(sql)
84
+ return
85
+
86
+ ddl = f"CREATE OR REPLACE VIEW {self.qualified_name} AS\n{sql}"
87
+ self.tx.execute(ddl)
88
+
89
+ def _has_privilege(self, grantee: str, privilege: str) -> bool:
90
+ # PUBLIC is a PostgreSQL pseudo-role for GRANT, but it's not necessarily a
91
+ # real role that can be passed to has_table_privilege() in all setups.
92
+ # GRANT itself is idempotent, so for PUBLIC we just apply it unconditionally.
93
+ if str(grantee).strip().upper() == "PUBLIC":
94
+ return False
95
+
96
+ # has_table_privilege(role, table, privilege)
97
+ # Note: privilege must be like 'SELECT'.
98
+ result = self.tx.execute(
99
+ "SELECT has_table_privilege(%s, %s, %s) as ok",
100
+ [grantee, f"{self.schema}.{self.name}", privilege],
101
+ )
102
+ try:
103
+ rows = result.as_dict().all()
104
+ except Exception:
105
+ rows = []
106
+ row = rows[0] if rows else None
107
+ return bool(row.get("ok")) if isinstance(row, dict) and row else False
108
+
109
+ def grant(self, privilege: str = "SELECT", grantee: str = "PUBLIC") -> None:
110
+ if self.tx.engine.schema_locked:
111
+ raise exceptions.DbSchemaLockedError(
112
+ f"Cannot grant privileges on {self.schema}.{self.name}: schema is locked"
113
+ )
114
+ privilege = str(privilege).upper().strip()
115
+ if not privilege:
116
+ raise ValueError("privilege cannot be empty")
117
+ self.tx.execute(
118
+ f"GRANT {privilege} ON {self.qualified_name} TO {_grantee_sql(grantee)}"
119
+ )
120
+
121
+ def ensure(
122
+ self,
123
+ select_sql: str | None = None,
124
+ *,
125
+ replace_existing: bool = False,
126
+ grants: list[Grant] | None = None,
127
+ grant_public_select: bool = True,
128
+ ) -> None:
129
+ """Ensure the view exists and has required grants.
130
+
131
+ - If the view does not exist, it is created using select_sql (required).
132
+ - If it exists, it is not modified unless replace_existing=True.
133
+ - Missing privileges are granted.
134
+ """
135
+
136
+ exists = self.exists()
137
+
138
+ if not exists:
139
+ if select_sql is None:
140
+ raise ValueError("select_sql is required when creating a missing view")
141
+ self.create_or_replace(select_sql)
142
+ elif replace_existing and select_sql is not None:
143
+ self.create_or_replace(select_sql)
144
+
145
+ desired_grants: list[Grant] = []
146
+ if grant_public_select:
147
+ desired_grants.append(Grant("SELECT", "PUBLIC"))
148
+ if grants:
149
+ desired_grants.extend(grants)
150
+
151
+ for g in desired_grants:
152
+ privilege = str(g.privilege).upper().strip()
153
+ grantee = str(g.grantee).strip()
154
+ if not privilege or not grantee:
155
+ continue
156
+
157
+ if not self._has_privilege(grantee, privilege):
158
+ self.grant(privilege=privilege, grantee=grantee)
@@ -52,6 +52,43 @@ class BaseSQLDialect(ABC):
52
52
  """
53
53
  pass
54
54
 
55
+ @classmethod
56
+ def is_connection_error_message(cls, msg: str) -> bool:
57
+ """Return True if an error message indicates a connection-level failure.
58
+
59
+ Dialects should override/extend this if they can do better than message matching.
60
+ Engine uses this only as a fallback when no/unknown error code is available.
61
+ """
62
+ if not msg:
63
+ return False
64
+ m = str(msg).strip().lower()
65
+ needles = (
66
+ "server closed the connection unexpectedly",
67
+ "no connection to the server",
68
+ "connection timed out",
69
+ "could not connect to server",
70
+ "cannot connect to server",
71
+ "connection already closed",
72
+ "cursor already closed",
73
+ "ssl syscall error",
74
+ "eof detected",
75
+ "connection reset by peer",
76
+ "broken pipe",
77
+ "terminating connection due to administrator command",
78
+ "could not receive data from server",
79
+ "could not send data to server",
80
+ )
81
+ return any(n in m for n in needles)
82
+
83
+ @classmethod
84
+ def is_transient_connection_error_message(cls, msg: str) -> bool:
85
+ """Return True if a connection error looks transient/retryable.
86
+
87
+ Default implementation treats most low-level disconnects as transient.
88
+ Dialects may override to be stricter.
89
+ """
90
+ return cls.is_connection_error_message(msg)
91
+
55
92
  # Core CRUD Operations
56
93
  @classmethod
57
94
  @abstractmethod
@@ -0,0 +1,97 @@
1
+ import unittest
2
+ from types import SimpleNamespace
3
+
4
+ from velocity.db.core.view import View
5
+
6
+
7
+ class FakeResult:
8
+ def __init__(self, rows):
9
+ self._rows = rows
10
+
11
+ def as_dict(self):
12
+ return self
13
+
14
+ def all(self):
15
+ return list(self._rows)
16
+
17
+
18
+ class FakeTx:
19
+ def __init__(self):
20
+ self.executed = []
21
+ self.engine = SimpleNamespace(
22
+ schema_locked=False,
23
+ sql=SimpleNamespace(default_schema="public"),
24
+ )
25
+
26
+ # Toggleable responses
27
+ self._view_exists = False
28
+ self._has_priv = False
29
+
30
+ def execute(self, sql, parms=None, *args, **kwargs):
31
+ self.executed.append((sql, parms))
32
+
33
+ text = str(sql)
34
+ if "information_schema.views" in text:
35
+ return FakeResult([{"ok": True}]) if self._view_exists else FakeResult([])
36
+
37
+ if "has_table_privilege" in text:
38
+ return FakeResult([{"ok": bool(self._has_priv)}])
39
+
40
+ return FakeResult([])
41
+
42
+
43
+ class TestViewHelper(unittest.TestCase):
44
+ def test_ensure_creates_missing_view_and_grants(self):
45
+ tx = FakeTx()
46
+ v = View(tx, "my_view")
47
+
48
+ tx._view_exists = False
49
+ tx._has_priv = False
50
+
51
+ v.ensure("select 1 as one")
52
+
53
+ statements = "\n".join(s for s, _ in tx.executed)
54
+ self.assertIn("CREATE OR REPLACE VIEW", statements)
55
+ self.assertIn('"public"."my_view"', statements)
56
+ self.assertIn("GRANT SELECT ON", statements)
57
+
58
+ def test_ensure_existing_view_does_not_replace_by_default(self):
59
+ tx = FakeTx()
60
+ v = View(tx, "my_view")
61
+
62
+ tx._view_exists = True
63
+ tx._has_priv = False
64
+
65
+ v.ensure("select 1 as one")
66
+
67
+ statements = "\n".join(s for s, _ in tx.executed)
68
+ self.assertNotIn("CREATE OR REPLACE VIEW", statements)
69
+ self.assertIn("GRANT SELECT ON", statements)
70
+
71
+ def test_ensure_existing_view_replaces_when_requested(self):
72
+ tx = FakeTx()
73
+ v = View(tx, "my_view")
74
+
75
+ tx._view_exists = True
76
+ tx._has_priv = True
77
+
78
+ v.ensure("select 1 as one", replace_existing=True)
79
+
80
+ statements = "\n".join(s for s, _ in tx.executed)
81
+ self.assertIn("CREATE OR REPLACE VIEW", statements)
82
+
83
+ def test_ensure_skips_grant_if_already_present(self):
84
+ tx = FakeTx()
85
+ v = View(tx, "my_view")
86
+
87
+ tx._view_exists = True
88
+ tx._has_priv = True
89
+
90
+ v.ensure("select 1 as one")
91
+
92
+ statements = "\n".join(s for s, _ in tx.executed)
93
+ self.assertNotIn("GRANT SELECT ON", statements)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.203
3
+ Version: 0.0.205
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
@@ -42,6 +42,7 @@ src/velocity/db/core/row.py
42
42
  src/velocity/db/core/sequence.py
43
43
  src/velocity/db/core/table.py
44
44
  src/velocity/db/core/transaction.py
45
+ src/velocity/db/core/view.py
45
46
  src/velocity/db/servers/__init__.py
46
47
  src/velocity/db/servers/tablehelper.py
47
48
  src/velocity/db/servers/base/__init__.py
@@ -83,6 +84,7 @@ src/velocity/db/tests/test_schema_locking_initializers.py
83
84
  src/velocity/db/tests/test_schema_locking_simple.py
84
85
  src/velocity/db/tests/test_sql_builder.py
85
86
  src/velocity/db/tests/test_tablehelper.py
87
+ src/velocity/db/tests/test_view_helper.py
86
88
  src/velocity/db/tests/postgres/__init__.py
87
89
  src/velocity/db/tests/postgres/common.py
88
90
  src/velocity/db/tests/postgres/test_column.py