velocity-python 0.0.116__py3-none-any.whl → 0.0.118__py3-none-any.whl

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.

Potentially problematic release.


This version of velocity-python might be problematic. Click here for more details.

Files changed (36) hide show
  1. velocity/__init__.py +3 -1
  2. velocity/aws/__init__.py +3 -0
  3. velocity/aws/amplify.py +10 -6
  4. velocity/aws/handlers/__init__.py +2 -0
  5. velocity/aws/handlers/base_handler.py +245 -0
  6. velocity/aws/handlers/context.py +58 -41
  7. velocity/aws/handlers/exceptions.py +16 -0
  8. velocity/aws/handlers/lambda_handler.py +15 -84
  9. velocity/aws/handlers/response.py +1 -1
  10. velocity/aws/handlers/sqs_handler.py +23 -144
  11. velocity/db/__init__.py +16 -1
  12. velocity/db/core/decorators.py +0 -1
  13. velocity/db/core/engine.py +33 -31
  14. velocity/db/core/exceptions.py +3 -0
  15. velocity/db/core/result.py +30 -24
  16. velocity/db/core/row.py +3 -1
  17. velocity/db/core/table.py +6 -5
  18. velocity/db/exceptions.py +35 -18
  19. velocity/db/servers/mysql.py +2 -3
  20. velocity/db/servers/postgres/__init__.py +10 -12
  21. velocity/db/servers/postgres/sql.py +36 -17
  22. velocity/db/servers/sqlite.py +2 -2
  23. velocity/db/servers/sqlserver.py +3 -3
  24. velocity/db/servers/tablehelper.py +117 -91
  25. velocity/db/utils.py +62 -47
  26. velocity/misc/conv/__init__.py +2 -0
  27. velocity/misc/conv/iconv.py +5 -4
  28. velocity/misc/export.py +1 -4
  29. velocity/misc/merge.py +1 -1
  30. velocity/misc/tools.py +0 -1
  31. {velocity_python-0.0.116.dist-info → velocity_python-0.0.118.dist-info}/METADATA +1 -1
  32. velocity_python-0.0.118.dist-info/RECORD +58 -0
  33. velocity_python-0.0.116.dist-info/RECORD +0 -56
  34. {velocity_python-0.0.116.dist-info → velocity_python-0.0.118.dist-info}/WHEEL +0 -0
  35. {velocity_python-0.0.116.dist-info → velocity_python-0.0.118.dist-info}/licenses/LICENSE +0 -0
  36. {velocity_python-0.0.116.dist-info → velocity_python-0.0.118.dist-info}/top_level.txt +0 -0
@@ -6,109 +6,59 @@ It includes logging capabilities, action routing, and error handling.
6
6
  """
7
7
 
8
8
  import json
9
- import os
10
- import sys
11
- import traceback
12
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict
13
10
 
14
- from velocity.aws import DEBUG
15
11
  from velocity.aws.handlers import context as VelocityContext
16
- from velocity.misc.format import to_json
12
+ from velocity.aws.handlers.base_handler import BaseHandler
17
13
 
18
14
 
19
- class SqsHandler:
15
+ class SqsHandler(BaseHandler):
20
16
  """
21
17
  Base class for handling SQS events in AWS Lambda functions.
22
-
18
+
23
19
  Provides structured processing of SQS records with automatic action routing,
24
20
  logging capabilities, and error handling hooks.
25
21
  """
26
22
 
27
- def __init__(self, aws_event: Dict[str, Any], aws_context: Any,
28
- context_class=VelocityContext.Context):
23
+ def __init__(
24
+ self,
25
+ aws_event: Dict[str, Any],
26
+ aws_context: Any,
27
+ context_class=VelocityContext.Context,
28
+ ):
29
29
  """
30
30
  Initialize the SQS handler.
31
-
31
+
32
32
  Args:
33
33
  aws_event: The AWS Lambda event containing SQS records
34
34
  aws_context: The AWS Lambda context object
35
35
  context_class: The context class to use for processing
36
36
  """
37
- self.aws_event = aws_event
38
- self.aws_context = aws_context
39
- self.serve_action_default = True
40
- self.skip_action = False
41
- self.ContextClass = context_class
42
-
43
- def log(self, tx, message: str, function: Optional[str] = None):
44
- """
45
- Log a message to the system log table.
46
-
47
- Args:
48
- tx: Database transaction object
49
- message: The message to log
50
- function: Optional function name, auto-detected if not provided
51
- """
52
- if not function:
53
- function = self._get_calling_function()
54
-
55
- data = {
56
- "app_name": os.environ.get("ProjectName", "Unknown"),
57
- "referer": "SQS",
58
- "user_agent": "QueueHandler",
59
- "device_type": "Lambda",
60
- "function": function,
61
- "message": message,
62
- "sys_modified_by": "Lambda:BackOfficeQueueHandler",
63
- }
64
- tx.table("sys_log").insert(data)
65
-
66
- def _get_calling_function(self) -> str:
67
- """
68
- Get the name of the calling function by inspecting the call stack.
69
-
70
- Returns:
71
- The name of the calling function or "<Unknown>" if not found
72
- """
73
- skip_functions = {"x", "log", "_transaction", "_get_calling_function"}
74
-
75
- for idx in range(10): # Limit search to prevent infinite loops
76
- try:
77
- frame = sys._getframe(idx)
78
- function_name = frame.f_code.co_name
79
-
80
- if function_name not in skip_functions:
81
- return function_name
82
-
83
- except ValueError:
84
- # No more frames in the stack
85
- break
86
-
87
- return "<Unknown>"
37
+ super().__init__(aws_event, aws_context, context_class)
88
38
 
89
39
  def serve(self, tx):
90
40
  """
91
41
  Process all SQS records in the event.
92
-
42
+
93
43
  Args:
94
44
  tx: Database transaction object
95
45
  """
96
46
  records = self.aws_event.get("Records", [])
97
-
47
+
98
48
  for record in records:
99
49
  self._process_record(tx, record)
100
-
50
+
101
51
  def _process_record(self, tx, record: Dict[str, Any]):
102
52
  """
103
53
  Process a single SQS record.
104
-
54
+
105
55
  Args:
106
56
  tx: Database transaction object
107
57
  record: Individual SQS record to process
108
58
  """
109
59
  attrs = record.get("attributes", {})
110
60
  postdata = {}
111
-
61
+
112
62
  # Parse message body if present
113
63
  body = record.get("body")
114
64
  if body:
@@ -127,93 +77,22 @@ class SqsHandler:
127
77
  response=None,
128
78
  session=None,
129
79
  )
130
-
80
+
81
+ # Use BaseHandler's execute_actions method
131
82
  try:
132
- self._execute_actions(local_context)
83
+ self.execute_actions(tx, local_context, postdata, attrs)
133
84
  except Exception as e:
134
- if hasattr(self, "onError"):
135
- self.onError(
136
- local_context,
137
- exc=e.__class__.__name__,
138
- tb=traceback.format_exc(),
139
- )
140
- else:
141
- # Re-raise if no error handler is defined
142
- raise
143
-
144
- def _execute_actions(self, local_context):
145
- """
146
- Execute the appropriate actions for the given context.
147
-
148
- Args:
149
- local_context: The context object for this record
150
- """
151
- # Execute beforeAction hook if available
152
- if hasattr(self, "beforeAction"):
153
- self.beforeAction(local_context)
154
-
155
- # Determine which actions to execute
156
- actions = self._get_actions_to_execute(local_context)
157
-
158
- # Execute the first matching action
159
- for action in actions:
160
- if self.skip_action:
161
- return
162
-
163
- if hasattr(self, action):
164
- getattr(self, action)(local_context)
165
- break
166
-
167
- # Execute afterAction hook if available
168
- if hasattr(self, "afterAction"):
169
- self.afterAction(local_context)
170
-
171
- def _get_actions_to_execute(self, local_context) -> list:
172
- """
173
- Get the list of actions to execute for the given context.
174
-
175
- Args:
176
- local_context: The context object for this record
177
-
178
- Returns:
179
- List of action method names to try executing
180
- """
181
- actions = []
182
-
183
- # Add specific action if available
184
- action = local_context.action()
185
- if action:
186
- action_method = self._format_action_name(action)
187
- actions.append(action_method)
188
-
189
- # Add default action if enabled
190
- if self.serve_action_default:
191
- actions.append("OnActionDefault")
192
-
193
- return actions
194
-
195
- def _format_action_name(self, action: str) -> str:
196
- """
197
- Format an action string into a method name.
198
-
199
- Args:
200
- action: The raw action string
201
-
202
- Returns:
203
- Formatted method name
204
- """
205
- formatted = action.replace('-', ' ').replace('_', ' ')
206
- return f"on action {formatted}".title().replace(" ", "")
85
+ self.handle_error(local_context, e)
207
86
 
208
87
  def OnActionDefault(self, tx, context):
209
88
  """
210
89
  Default action handler when no specific action is found.
211
-
90
+
212
91
  Args:
213
92
  tx: Database transaction object
214
93
  context: The context object for this record
215
94
  """
216
- action = context.action() if hasattr(context, 'action') else 'unknown'
95
+ action = context.action() if hasattr(context, "action") else "unknown"
217
96
  warning_message = (
218
97
  f"[Warn] Action handler not found. Calling default action "
219
98
  f"`SqsHandler.OnActionDefault` with the following parameters:\n"
velocity/db/__init__.py CHANGED
@@ -15,5 +15,20 @@ from velocity.db.utils import (
15
15
  safe_sort_key_none_first,
16
16
  safe_sort_key_with_default,
17
17
  group_by_fields,
18
- safe_sort_grouped_rows
18
+ safe_sort_grouped_rows,
19
19
  )
20
+
21
+ __all__ = [
22
+ "exceptions",
23
+ "postgres",
24
+ "mysql",
25
+ "sqlite",
26
+ "sqlserver",
27
+ "utils",
28
+ "safe_sort_rows",
29
+ "safe_sort_key_none_last",
30
+ "safe_sort_key_none_first",
31
+ "safe_sort_key_with_default",
32
+ "group_by_fields",
33
+ "safe_sort_grouped_rows",
34
+ ]
@@ -1,6 +1,5 @@
1
1
  import time
2
2
  import random
3
- import traceback
4
3
  from functools import wraps
5
4
  from velocity.db import exceptions
6
5
 
@@ -1,7 +1,5 @@
1
1
  import inspect
2
- import sys
3
2
  import re
4
- import traceback
5
3
  from functools import wraps
6
4
  from velocity.db import exceptions
7
5
  from velocity.db.core.transaction import Transaction
@@ -176,18 +174,18 @@ class Engine:
176
174
  while True:
177
175
  try:
178
176
  return function(*args, **kwds)
179
- except exceptions.DbRetryTransaction as e:
177
+ except exceptions.DbRetryTransaction:
180
178
  retry_count += 1
181
179
  if retry_count > self.MAX_RETRIES:
182
180
  raise
183
181
  _tx.rollback()
184
- except exceptions.DbLockTimeoutError as e:
182
+ except exceptions.DbLockTimeoutError:
185
183
  lock_timeout_count += 1
186
184
  if lock_timeout_count > self.MAX_RETRIES:
187
185
  raise
188
186
  _tx.rollback()
189
187
  continue
190
- except:
188
+ except Exception:
191
189
  raise
192
190
  finally:
193
191
  setattr(_tx, "_exec_function_depth", depth)
@@ -330,30 +328,32 @@ class Engine:
330
328
  Central method to parse driver exceptions and re-raise them as our custom exceptions.
331
329
  """
332
330
  logger = logging.getLogger(__name__)
333
-
331
+
334
332
  # If it's already a velocity exception, just re-raise it
335
333
  if isinstance(exception, exceptions.DbException):
336
334
  raise exception
337
-
335
+
338
336
  # Get error code and message from the SQL driver
339
337
  try:
340
338
  error_code, error_message = self.sql.get_error(exception)
341
339
  except Exception:
342
340
  error_code, error_message = None, str(exception)
343
-
341
+
344
342
  msg = str(exception).strip().lower()
345
-
343
+
346
344
  # Create enhanced error message with SQL query
347
345
  enhanced_message = str(exception)
348
346
  if sql:
349
- enhanced_message += f"\n\nSQL Query:\n{self._format_sql_with_params(sql, parameters)}"
350
-
347
+ enhanced_message += (
348
+ f"\n\nSQL Query:\n{self._format_sql_with_params(sql, parameters)}"
349
+ )
350
+
351
351
  logger.warning(
352
352
  "Database error caught. Attempting to transform: code=%s message=%s",
353
353
  error_code,
354
354
  error_message,
355
355
  )
356
-
356
+
357
357
  # Direct error code mapping
358
358
  if error_code in self.sql.ApplicationErrorCodes:
359
359
  raise exceptions.DbApplicationError(enhanced_message) from exception
@@ -379,7 +379,7 @@ class Engine:
379
379
  raise exceptions.DbLockTimeoutError(enhanced_message) from exception
380
380
  if error_code in self.sql.RetryTransactionCodes:
381
381
  raise exceptions.DbRetryTransaction(enhanced_message) from exception
382
-
382
+
383
383
  # Regex-based fallback patterns
384
384
  if re.search(r"key \(sys_id\)=\(\d+\) already exists.", msg, re.M):
385
385
  raise exceptions.DbDuplicateKeyError(enhanced_message) from exception
@@ -405,7 +405,7 @@ class Engine:
405
405
  raise exceptions.DbConnectionError(enhanced_message) from exception
406
406
  if "no such table:" in msg:
407
407
  raise exceptions.DbTableMissingError(enhanced_message) from exception
408
-
408
+
409
409
  logger.error(
410
410
  "Unhandled/Unknown Error in engine.process_error",
411
411
  exc_info=True,
@@ -416,7 +416,7 @@ class Engine:
416
416
  "sql_params": parameters,
417
417
  },
418
418
  )
419
-
419
+
420
420
  # If we can't classify it, re-raise with enhanced message
421
421
  raise type(exception)(enhanced_message) from exception
422
422
 
@@ -426,10 +426,10 @@ class Engine:
426
426
  """
427
427
  if not sql:
428
428
  return "No SQL provided"
429
-
429
+
430
430
  if not parameters:
431
431
  return sql
432
-
432
+
433
433
  try:
434
434
  # Handle different parameter formats
435
435
  if isinstance(parameters, (list, tuple)):
@@ -437,45 +437,47 @@ class Engine:
437
437
  formatted_params = []
438
438
  for param in parameters:
439
439
  if param is None:
440
- formatted_params.append('NULL')
440
+ formatted_params.append("NULL")
441
441
  elif isinstance(param, str):
442
442
  # Escape single quotes and wrap in quotes
443
443
  escaped = param.replace("'", "''")
444
444
  formatted_params.append(f"'{escaped}'")
445
445
  elif isinstance(param, bool):
446
- formatted_params.append('TRUE' if param else 'FALSE')
446
+ formatted_params.append("TRUE" if param else "FALSE")
447
447
  else:
448
448
  formatted_params.append(str(param))
449
-
449
+
450
450
  # Replace %s placeholders with actual values
451
451
  formatted_sql = sql
452
452
  for param in formatted_params:
453
- formatted_sql = formatted_sql.replace('%s', param, 1)
454
-
453
+ formatted_sql = formatted_sql.replace("%s", param, 1)
454
+
455
455
  return formatted_sql
456
-
456
+
457
457
  elif isinstance(parameters, dict):
458
458
  # Handle named parameters
459
459
  formatted_sql = sql
460
460
  for key, value in parameters.items():
461
461
  if value is None:
462
- replacement = 'NULL'
462
+ replacement = "NULL"
463
463
  elif isinstance(value, str):
464
464
  escaped = value.replace("'", "''")
465
465
  replacement = f"'{escaped}'"
466
466
  elif isinstance(value, bool):
467
- replacement = 'TRUE' if value else 'FALSE'
467
+ replacement = "TRUE" if value else "FALSE"
468
468
  else:
469
469
  replacement = str(value)
470
-
470
+
471
471
  # Replace %(key)s or :key patterns
472
- formatted_sql = formatted_sql.replace(f'%({key})s', replacement)
473
- formatted_sql = formatted_sql.replace(f':{key}', replacement)
474
-
472
+ formatted_sql = formatted_sql.replace(f"%({key})s", replacement)
473
+ formatted_sql = formatted_sql.replace(f":{key}", replacement)
474
+
475
475
  return formatted_sql
476
476
  else:
477
477
  return f"{sql}\n-- Parameters: {parameters}"
478
-
478
+
479
479
  except Exception as e:
480
480
  # If formatting fails, return original SQL with parameters shown separately
481
- return f"{sql}\n-- Parameters (formatting failed): {parameters}\n-- Error: {e}"
481
+ return (
482
+ f"{sql}\n-- Parameters (formatting failed): {parameters}\n-- Error: {e}"
483
+ )
@@ -56,15 +56,18 @@ class DuplicateRowsFoundError(Exception):
56
56
 
57
57
  class DbQueryError(DbException):
58
58
  """Database query error"""
59
+
59
60
  pass
60
61
 
61
62
 
62
63
  class DbTransactionError(DbException):
63
64
  """Database transaction error"""
65
+
64
66
  pass
65
67
 
66
68
 
67
69
  # Add aliases for backward compatibility with engine.py
68
70
  class DatabaseError(DbException):
69
71
  """Generic database error - alias for DbException"""
72
+
70
73
  pass
@@ -1,5 +1,3 @@
1
- import datetime
2
- import decimal
3
1
  from velocity.misc.format import to_json
4
2
 
5
3
 
@@ -7,14 +5,14 @@ class Result:
7
5
  """
8
6
  Wraps a database cursor to provide various convenience transformations
9
7
  (dict, list, tuple, etc.) and helps iterate over query results.
10
-
8
+
11
9
  Features:
12
10
  - Pre-fetches first row for immediate boolean evaluation
13
11
  - Boolean state changes as rows are consumed: bool(result) tells you if MORE rows are available
14
12
  - Supports __bool__, is_empty(), has_results() for checking remaining results
15
13
  - Efficient iteration without unnecessary fetchall() calls
16
14
  - Caches next row to maintain accurate state without redundant database calls
17
-
15
+
18
16
  Boolean Behavior:
19
17
  - Initially: bool(result) = True if query returned any rows
20
18
  - After each row: bool(result) = True if more rows are available to fetch
@@ -29,15 +27,15 @@ class Result:
29
27
  description = getattr(cursor, "description", []) or []
30
28
  self._headers = []
31
29
  for col in description:
32
- if hasattr(col, '__getitem__'): # Tuple-like (col[0])
30
+ if hasattr(col, "__getitem__"): # Tuple-like (col[0])
33
31
  self._headers.append(col[0].lower())
34
- elif hasattr(col, 'name'): # Object with name attribute
32
+ elif hasattr(col, "name"): # Object with name attribute
35
33
  self._headers.append(col.name.lower())
36
34
  else:
37
- self._headers.append(f'column_{len(self._headers)}')
35
+ self._headers.append(f"column_{len(self._headers)}")
38
36
  except (AttributeError, TypeError, IndexError):
39
37
  self._headers = []
40
-
38
+
41
39
  self.__as_strings = False
42
40
  self.__enumerate = False
43
41
  self.__count = -1
@@ -49,7 +47,7 @@ class Result:
49
47
  self._cached_first_row = None
50
48
  self._first_row_fetched = False
51
49
  self._exhausted = False
52
-
50
+
53
51
  # Pre-fetch the first row to enable immediate boolean evaluation
54
52
  self._fetch_first_row()
55
53
 
@@ -60,14 +58,16 @@ class Result:
60
58
  """
61
59
  if self._first_row_fetched or not self._cursor:
62
60
  return
63
-
61
+
64
62
  # Don't try to fetch from INSERT/UPDATE/DELETE operations
65
63
  # These operations don't return rows, only rowcount
66
- if self.__sql and self.__sql.strip().upper().startswith(('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE')):
64
+ if self.__sql and self.__sql.strip().upper().startswith(
65
+ ("INSERT", "UPDATE", "DELETE", "TRUNCATE")
66
+ ):
67
67
  self._exhausted = True
68
68
  self._first_row_fetched = True
69
69
  return
70
-
70
+
71
71
  try:
72
72
  raw_row = self._cursor.fetchone()
73
73
  if raw_row:
@@ -109,7 +109,9 @@ class Result:
109
109
  Return True if there are more rows available to fetch.
110
110
  This is based on whether we have a cached row or the cursor isn't exhausted.
111
111
  """
112
- return self._cached_first_row is not None or (not self._exhausted and self._cursor)
112
+ return self._cached_first_row is not None or (
113
+ not self._exhausted and self._cursor
114
+ )
113
115
 
114
116
  def __next__(self):
115
117
  """
@@ -127,7 +129,7 @@ class Result:
127
129
  if not row:
128
130
  self._exhausted = True
129
131
  raise StopIteration
130
- # Try to pre-fetch the next row to update our state
132
+ # Try to pre-fetch the next row to update our state
131
133
  self._try_cache_next_row()
132
134
  except Exception as e:
133
135
  # Handle cursor errors (e.g., closed cursor)
@@ -154,7 +156,7 @@ class Result:
154
156
  """
155
157
  if not self._cursor or self._cached_first_row is not None:
156
158
  return
157
-
159
+
158
160
  try:
159
161
  next_row = self._cursor.fetchone()
160
162
  if next_row:
@@ -214,18 +216,22 @@ class Result:
214
216
  """
215
217
  if not self.__columns and self._cursor and hasattr(self._cursor, "description"):
216
218
  for column in self._cursor.description:
217
- data = {
218
- "type_name": "unknown" # Default value
219
- }
220
-
219
+ data = {"type_name": "unknown"} # Default value
220
+
221
221
  # Try to get type information (PostgreSQL specific)
222
222
  try:
223
- if hasattr(column, 'type_code') and self.__tx and hasattr(self.__tx, 'pg_types'):
224
- data["type_name"] = self.__tx.pg_types.get(column.type_code, "unknown")
223
+ if (
224
+ hasattr(column, "type_code")
225
+ and self.__tx
226
+ and hasattr(self.__tx, "pg_types")
227
+ ):
228
+ data["type_name"] = self.__tx.pg_types.get(
229
+ column.type_code, "unknown"
230
+ )
225
231
  except (AttributeError, KeyError):
226
232
  # Keep default value
227
233
  pass
228
-
234
+
229
235
  # Get all other column attributes safely
230
236
  for key in dir(column):
231
237
  if not key.startswith("__"):
@@ -234,8 +240,8 @@ class Result:
234
240
  except (AttributeError, TypeError):
235
241
  # Skip attributes that can't be accessed
236
242
  continue
237
-
238
- column_name = getattr(column, 'name', f'column_{len(self.__columns)}')
243
+
244
+ column_name = getattr(column, "name", f"column_{len(self.__columns)}")
239
245
  self.__columns[column_name] = data
240
246
  return self.__columns
241
247
 
velocity/db/core/row.py CHANGED
@@ -97,7 +97,9 @@ class Row:
97
97
  except Exception as e:
98
98
  # Check if the error message indicates a missing column
99
99
  error_msg = str(e).lower()
100
- if 'column' in error_msg and ('does not exist' in error_msg or 'not found' in error_msg):
100
+ if "column" in error_msg and (
101
+ "does not exist" in error_msg or "not found" in error_msg
102
+ ):
101
103
  return failobj
102
104
  # Re-raise other exceptions
103
105
  raise
velocity/db/core/table.py CHANGED
@@ -54,13 +54,13 @@ class Table:
54
54
  """
55
55
  try:
56
56
  self._cursor.close()
57
- except:
57
+ except Exception:
58
58
  pass
59
59
 
60
60
  def cursor(self):
61
61
  try:
62
62
  return self._cursor
63
- except:
63
+ except AttributeError:
64
64
  pass
65
65
  self._cursor = self.tx.cursor()
66
66
  return self._cursor
@@ -287,7 +287,8 @@ class Table:
287
287
  if use_where:
288
288
  return Row(self, where, lock=lock)
289
289
  return Row(self, result[0]["sys_id"], lock=lock)
290
- one=find
290
+
291
+ one = find
291
292
 
292
293
  @return_default(None)
293
294
  def first(
@@ -886,8 +887,8 @@ class Table:
886
887
  # Return a descriptive string of differences.
887
888
  if differences:
888
889
  differences.insert(0, f"Comparing {self.name}: {pk1} vs {pk2}")
889
- differences.insert(0, f"--------------------------------------")
890
- differences.append(f"--------------------------------------")
890
+ differences.insert(0, "--------------------------------------")
891
+ differences.append("--------------------------------------")
891
892
  return "\n".join(differences)
892
893
  else:
893
894
  return f"{self.name} rows {pk1} and {pk2} are identical."