velocity-python 0.0.109__py3-none-any.whl → 0.0.112__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.

velocity/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.109"
1
+ __version__ = version = "0.0.112"
2
2
 
3
3
  from . import aws
4
4
  from . import db
@@ -24,38 +24,6 @@ class Engine:
24
24
  self.__sql = sql
25
25
  self.__driver = driver
26
26
  self.__connect_timeout = connect_timeout
27
-
28
- # Set up error code mappings from the SQL class
29
- self._setup_error_mappings()
30
-
31
- def _setup_error_mappings(self):
32
- """
33
- Set up error code to exception class mappings from the SQL driver.
34
- """
35
- self.error_codes = {}
36
-
37
- # Map error codes to exception class names
38
- sql_attrs = [
39
- ('ApplicationErrorCodes', 'DbApplicationError'),
40
- ('DatabaseMissingErrorCodes', 'DbDatabaseMissingError'),
41
- ('TableMissingErrorCodes', 'DbTableMissingError'),
42
- ('ColumnMissingErrorCodes', 'DbColumnMissingError'),
43
- ('ForeignKeyMissingErrorCodes', 'DbForeignKeyMissingError'),
44
- ('ConnectionErrorCodes', 'DbConnectionError'),
45
- ('DuplicateKeyErrorCodes', 'DbDuplicateKeyError'),
46
- ('RetryTransactionCodes', 'DbRetryTransaction'),
47
- ('TruncationErrorCodes', 'DbTruncationError'),
48
- ('LockTimeoutErrorCodes', 'DbLockTimeoutError'),
49
- ('DatabaseObjectExistsErrorCodes', 'DbObjectExistsError'),
50
- ('DataIntegrityErrorCodes', 'DbDataIntegrityError')
51
- ]
52
-
53
- for attr_name, exception_class in sql_attrs:
54
- if hasattr(self.sql, attr_name):
55
- codes = getattr(self.sql, attr_name)
56
- if codes: # Only add non-empty lists
57
- for code in codes:
58
- self.error_codes[str(code)] = exception_class
59
27
 
60
28
  def __str__(self):
61
29
  return f"[{self.sql.server}] engine({self.config})"
@@ -359,833 +327,155 @@ class Engine:
359
327
 
360
328
  def process_error(self, exception, sql=None, parameters=None):
361
329
  """
362
- Process database errors and raise appropriate velocity exceptions.
363
- Enhanced for robustness with exception chaining and comprehensive error handling.
364
-
365
- Args:
366
- exception: The original exception from the database driver
367
- sql: The SQL statement that caused the error (optional)
368
- parameters: The parameters passed to the SQL statement (optional)
369
-
370
- Raises:
371
- The appropriate velocity exception with proper chaining
330
+ Central method to parse driver exceptions and re-raise them as our custom exceptions.
372
331
  """
373
332
  logger = logging.getLogger(__name__)
374
333
 
375
- # Enhanced logging with context - more readable format
376
- sql_preview = sql[:100] + "..." if sql and len(sql) > 100 else sql or "None"
377
-
378
- logger.error(
379
- f"🔴 Database Error Detected\n"
380
- f" Exception Type: {type(exception).__name__}\n"
381
- f" SQL Statement: {sql_preview}\n"
382
- f" Processing error for classification..."
383
- )
334
+ # If it's already a velocity exception, just re-raise it
335
+ if isinstance(exception, exceptions.DbException):
336
+ raise exception
384
337
 
385
- # Safely get error code and message with fallbacks
338
+ # Get error code and message from the SQL driver
386
339
  try:
387
- # Try PostgreSQL-specific error code first, then use SQL driver's get_error method
388
- error_code = getattr(exception, 'pgcode', None)
389
- if not error_code and hasattr(self.sql, 'get_error'):
390
- try:
391
- error_code, error_message_from_driver = self.sql.get_error(exception)
392
- if error_message_from_driver:
393
- error_message = error_message_from_driver
394
- except Exception as get_error_exception:
395
- logger.warning(f"⚠️ SQL driver get_error failed: {get_error_exception}")
396
- error_code = None
397
- except Exception as e:
398
- logger.warning(f"⚠️ Unable to extract database error code: {e}")
399
- error_code = None
400
-
401
- try:
402
- error_message = str(exception)
403
- except Exception as e:
404
- logger.warning(f"⚠️ Unable to convert exception to string: {e}")
405
- error_message = f"<Error converting exception: {type(exception).__name__}>"
406
-
407
- # Primary error classification by error code
408
- if error_code and hasattr(self, 'error_codes') and str(error_code) in self.error_codes:
409
- error_class = self.error_codes[str(error_code)]
410
- logger.info(f"✅ Successfully classified error: {error_code} → {error_class}")
411
- try:
412
- raise self._create_exception_with_chaining(
413
- error_class, error_message, exception, sql, parameters
414
- )
415
- except Exception as creation_error:
416
- logger.error(f"❌ Failed to create {error_class} exception: {creation_error}")
417
- # Fall through to regex classification
418
-
419
- # Secondary error classification by message patterns (regex fallback)
420
- error_message_lower = error_message.lower()
421
-
422
- # Enhanced connection error patterns
423
- connection_patterns = [
424
- r'connection.*refused|could not connect',
425
- r'network.*unreachable|network.*down',
426
- r'broken pipe|connection.*broken',
427
- r'timeout.*connection|connection.*timeout',
428
- r'server.*closed.*connection|connection.*lost',
429
- r'no route to host|host.*unreachable',
430
- r'connection.*reset|reset.*connection'
431
- ]
432
-
433
- # Enhanced duplicate key patterns
434
- duplicate_patterns = [
435
- r'duplicate.*key.*value|unique.*constraint.*violated',
436
- r'duplicate.*entry|key.*already.*exists',
437
- r'violates.*unique.*constraint',
438
- r'unique.*violation|constraint.*unique'
439
- ]
440
-
441
- # Enhanced permission/authorization patterns
442
- permission_patterns = [
443
- r'permission.*denied|access.*denied|authorization.*failed',
444
- r'insufficient.*privileges|privilege.*denied',
445
- r'not.*authorized|unauthorized.*access',
446
- r'authentication.*failed|login.*failed'
447
- ]
448
-
449
- # Enhanced database/table not found patterns
450
- not_found_patterns = [
451
- r'database.*does.*not.*exist|unknown.*database',
452
- r'table.*does.*not.*exist|relation.*does.*not.*exist',
453
- r'no.*such.*database|database.*not.*found',
454
- r'schema.*does.*not.*exist|unknown.*table'
455
- ]
456
-
457
- # Enhanced column missing patterns
458
- column_missing_patterns = [
459
- r'column.*does.*not.*exist',
460
- r'unknown.*column|column.*not.*found',
461
- r'no.*such.*column|invalid.*column.*name'
462
- ]
463
-
464
- # Enhanced syntax error patterns
465
- syntax_patterns = [
466
- r'syntax.*error|invalid.*syntax',
467
- r'malformed.*query|bad.*sql.*grammar',
468
- r'unexpected.*token|parse.*error'
469
- ]
470
-
471
- # Enhanced deadlock/timeout patterns
472
- deadlock_patterns = [
473
- r'deadlock.*detected|lock.*timeout',
474
- r'timeout.*waiting.*for.*lock|query.*timeout',
475
- r'lock.*wait.*timeout|deadlock.*found'
476
- ]
340
+ error_code, error_message = self.sql.get_error(exception)
341
+ except Exception:
342
+ error_code, error_message = None, str(exception)
477
343
 
478
- # Comprehensive pattern matching with error class mapping
479
- pattern_mappings = [
480
- (connection_patterns, 'DbConnectionError'),
481
- (duplicate_patterns, 'DbDuplicateKeyError'),
482
- (permission_patterns, 'DbPermissionError'),
483
- (not_found_patterns, 'DbTableMissingError'),
484
- (column_missing_patterns, 'DbColumnMissingError'),
485
- (syntax_patterns, 'DbSyntaxError'),
486
- (deadlock_patterns, 'DbDeadlockError')
487
- ]
344
+ msg = str(exception).strip().lower()
488
345
 
489
- # Apply pattern matching
490
- for patterns, error_class in pattern_mappings:
491
- for pattern in patterns:
492
- try:
493
- if re.search(pattern, error_message_lower):
494
- logger.info(f"✅ Classified error by pattern match: '{pattern}' → {error_class}")
495
- raise self._create_exception_with_chaining(
496
- error_class, error_message, exception, sql, parameters
497
- )
498
- except re.error as regex_error:
499
- logger.warning(f"⚠️ Regex pattern error for '{pattern}': {regex_error}")
500
- continue
501
- except Exception as pattern_error:
502
- logger.error(f"❌ Error applying pattern '{pattern}': {pattern_error}")
503
- continue
346
+ # Create enhanced error message with SQL query
347
+ enhanced_message = str(exception)
348
+ if sql:
349
+ enhanced_message += f"\n\nSQL Query:\n{self._format_sql_with_params(sql, parameters)}"
504
350
 
505
- # Fallback: return generic database error with full context
506
- available_codes = list(getattr(self, 'error_codes', {}).keys()) if hasattr(self, 'error_codes') else []
507
351
  logger.warning(
508
- f"⚠️ Unable to classify database error automatically\n"
509
- f" → Falling back to generic DatabaseError\n"
510
- f" → Error Code: {error_code or 'Unknown'}\n"
511
- f" → Available Classifications: {available_codes or 'None configured'}"
512
- )
513
-
514
- raise self._create_exception_with_chaining(
515
- 'DatabaseError', error_message, exception, sql, parameters
352
+ "Database error caught. Attempting to transform: code=%s message=%s",
353
+ error_code,
354
+ error_message,
516
355
  )
517
-
518
- def _format_human_readable_error(self, error_class, message, original_exception, sql=None, parameters=None, format_type='console'):
519
- """
520
- Format a human-readable error message with proper context and formatting.
521
-
522
- Args:
523
- error_class: The name of the exception class
524
- message: The raw error message
525
- original_exception: The original exception
526
- sql: The SQL statement (optional)
527
- parameters: The SQL parameters (optional)
528
- format_type: 'console' for terminal output, 'email' for HTML email format
529
-
530
- Returns:
531
- A nicely formatted, human-readable error message
532
- """
533
- if format_type == 'email':
534
- return self._format_email_error(error_class, message, original_exception, sql, parameters)
535
- else:
536
- return self._format_console_error(error_class, message, original_exception, sql, parameters)
537
-
538
- def _format_console_error(self, error_class, message, original_exception, sql=None, parameters=None):
539
- """
540
- Format error message for console/terminal output with Unicode box drawing.
541
- """
542
- # Map error classes to user-friendly descriptions
543
- error_descriptions = {
544
- 'DbColumnMissingError': 'Column Not Found',
545
- 'DbTableMissingError': 'Table Not Found',
546
- 'DbDatabaseMissingError': 'Database Not Found',
547
- 'DbForeignKeyMissingError': 'Foreign Key Constraint Violation',
548
- 'DbDuplicateKeyError': 'Duplicate Key Violation',
549
- 'DbConnectionError': 'Database Connection Failed',
550
- 'DbDataIntegrityError': 'Data Integrity Violation',
551
- 'DbQueryError': 'Query Execution Error',
552
- 'DbTransactionError': 'Transaction Error',
553
- 'DbTruncationError': 'Data Truncation Error',
554
- 'DatabaseError': 'Database Error'
555
- }
556
-
557
- # Get user-friendly error type
558
- friendly_type = error_descriptions.get(error_class, error_class.replace('Db', '').replace('Error', ' Error'))
559
-
560
- # Clean up the original message
561
- clean_message = str(message).strip()
562
-
563
- # Extract specific details from PostgreSQL errors
564
- details = self._extract_error_details(original_exception, clean_message)
565
-
566
- # Build the formatted message
567
- lines = []
568
- lines.append(f"╭─ {friendly_type} ─" + "─" * max(0, 60 - len(friendly_type)))
569
- lines.append("│")
570
356
 
571
- # Add the main error description
572
- if details.get('description'):
573
- lines.append(f"│ {details['description']}")
574
- else:
575
- lines.append(f"│ {clean_message}")
576
- lines.append("│")
357
+ # Direct error code mapping
358
+ if error_code in self.sql.ApplicationErrorCodes:
359
+ raise exceptions.DbApplicationError(enhanced_message) from exception
360
+ if error_code in self.sql.ColumnMissingErrorCodes:
361
+ raise exceptions.DbColumnMissingError(enhanced_message) from exception
362
+ if error_code in self.sql.TableMissingErrorCodes:
363
+ raise exceptions.DbTableMissingError(enhanced_message) from exception
364
+ if error_code in self.sql.DatabaseMissingErrorCodes:
365
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
366
+ if error_code in self.sql.ForeignKeyMissingErrorCodes:
367
+ raise exceptions.DbForeignKeyMissingError(enhanced_message) from exception
368
+ if error_code in self.sql.TruncationErrorCodes:
369
+ raise exceptions.DbTruncationError(enhanced_message) from exception
370
+ if error_code in self.sql.DataIntegrityErrorCodes:
371
+ raise exceptions.DbDataIntegrityError(enhanced_message) from exception
372
+ if error_code in self.sql.ConnectionErrorCodes:
373
+ raise exceptions.DbConnectionError(enhanced_message) from exception
374
+ if error_code in self.sql.DuplicateKeyErrorCodes:
375
+ raise exceptions.DbDuplicateKeyError(enhanced_message) from exception
376
+ if error_code in self.sql.DatabaseObjectExistsErrorCodes:
377
+ raise exceptions.DbObjectExistsError(enhanced_message) from exception
378
+ if error_code in self.sql.LockTimeoutErrorCodes:
379
+ raise exceptions.DbLockTimeoutError(enhanced_message) from exception
380
+ if error_code in self.sql.RetryTransactionCodes:
381
+ raise exceptions.DbRetryTransaction(enhanced_message) from exception
382
+
383
+ # Regex-based fallback patterns
384
+ if re.search(r"key \(sys_id\)=\(\d+\) already exists.", msg, re.M):
385
+ raise exceptions.DbDuplicateKeyError(enhanced_message) from exception
386
+ if re.findall(r"database.*does not exist", msg, re.M):
387
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
388
+ if re.findall(r"no such database", msg, re.M):
389
+ raise exceptions.DbDatabaseMissingError(enhanced_message) from exception
390
+ if re.findall(r"already exists", msg, re.M):
391
+ raise exceptions.DbObjectExistsError(enhanced_message) from exception
392
+ if re.findall(r"server closed the connection unexpectedly", msg, re.M):
393
+ raise exceptions.DbConnectionError(enhanced_message) from exception
394
+ if re.findall(r"no connection to the server", msg, re.M):
395
+ raise exceptions.DbConnectionError(enhanced_message) from exception
396
+ if re.findall(r"connection timed out", msg, re.M):
397
+ raise exceptions.DbConnectionError(enhanced_message) from exception
398
+ if re.findall(r"could not connect to server", msg, re.M):
399
+ raise exceptions.DbConnectionError(enhanced_message) from exception
400
+ if re.findall(r"cannot connect to server", msg, re.M):
401
+ raise exceptions.DbConnectionError(enhanced_message) from exception
402
+ if re.findall(r"connection already closed", msg, re.M):
403
+ raise exceptions.DbConnectionError(enhanced_message) from exception
404
+ if re.findall(r"cursor already closed", msg, re.M):
405
+ raise exceptions.DbConnectionError(enhanced_message) from exception
406
+ if "no such table:" in msg:
407
+ raise exceptions.DbTableMissingError(enhanced_message) from exception
577
408
 
578
- # Add error code if available
579
- error_code = getattr(original_exception, 'pgcode', None)
580
- if error_code:
581
- lines.append(f"│ Error Code: {error_code}")
582
-
583
- # Add specific details if available
584
- if details.get('column'):
585
- lines.append(f"│ Column: {details['column']}")
586
- if details.get('table'):
587
- lines.append(f"│ Table: {details['table']}")
588
- if details.get('constraint'):
589
- lines.append(f"│ Constraint: {details['constraint']}")
590
- if details.get('hint'):
591
- lines.append(f"│ Hint: {details['hint']}")
592
-
593
- # Add SQL context if available
594
- if sql:
595
- lines.append("│")
596
- lines.append("│ SQL Statement:")
597
- # Show complete SQL without truncation for debugging
598
- for line in sql.split('\n'):
599
- lines.append(f"│ {line.strip()}")
600
-
601
- # Add parameters if available
602
- if parameters is not None:
603
- lines.append("│")
604
- lines.append(f"│ Parameters: {parameters}")
605
-
606
- # Add debugging section with copy-paste ready format
607
- if sql or parameters is not None:
608
- lines.append("│")
609
- lines.append("│ ┌─ DEBUG COPY-PASTE SECTION ─────────────────────────────")
610
-
611
- if sql and parameters is not None:
612
- # Format for direct copy-paste into database console
613
- lines.append("│ │")
614
- lines.append("│ │ Complete SQL with Parameters:")
615
- lines.append("│ │ " + "─" * 45)
616
-
617
- # Show the raw SQL
618
- lines.append("│ │ Raw SQL:")
619
- for line in sql.split('\n'):
620
- lines.append(f"│ │ {line}")
621
-
622
- lines.append("│ │")
623
- lines.append(f"│ │ Raw Parameters: {parameters}")
624
-
625
- # Try to create an executable version
626
- lines.append("│ │")
627
- lines.append("│ │ Executable Format (for PostgreSQL):")
628
- lines.append("│ │ " + "─" * 35)
629
-
630
- try:
631
- # Create a version with parameters substituted for testing
632
- executable_sql = self._format_executable_sql(sql, parameters)
633
- for line in executable_sql.split('\n'):
634
- lines.append(f"│ │ {line}")
635
- except Exception:
636
- lines.append("│ │ [Unable to format executable SQL]")
637
- for line in sql.split('\n'):
638
- lines.append(f"│ │ {line}")
639
- lines.append(f"│ │ -- Parameters: {parameters}")
640
-
641
- elif sql:
642
- lines.append("│ │")
643
- lines.append("│ │ SQL Statement (no parameters):")
644
- lines.append("│ │ " + "─" * 30)
645
- for line in sql.split('\n'):
646
- lines.append(f"│ │ {line}")
647
-
648
- lines.append("│ │")
649
- lines.append("│ └─────────────────────────────────────────────────────────")
650
-
651
- # Add detailed call stack information for debugging
652
- stack_info = self._extract_call_stack_info()
653
- if stack_info:
654
- lines.append("│")
655
- lines.append("│ ┌─ CALL STACK ANALYSIS ──────────────────────────────────")
656
- lines.append("│ │")
657
-
658
- if stack_info.get('top_level_call'):
659
- lines.append("│ │ Top-Level Function (most helpful for debugging):")
660
- lines.append("│ │ " + "─" * 48)
661
- call = stack_info['top_level_call']
662
- lines.append(f"│ │ Function: {call['function']}")
663
- lines.append(f"│ │ File: {call['file']}")
664
- lines.append(f"│ │ Line: {call['line']}")
665
- if call.get('code'):
666
- lines.append(f"│ │ Code: {call['code'].strip()}")
667
-
668
- if stack_info.get('relevant_calls'):
669
- lines.append("│ │")
670
- lines.append("│ │ Relevant Call Chain (excluding middleware):")
671
- lines.append("│ │ " + "─" * 44)
672
- for i, call in enumerate(stack_info['relevant_calls'][:5], 1): # Show top 5
673
- lines.append(f"│ │ {i}. {call['function']} in {call['file']}:{call['line']}")
674
- if call.get('code'):
675
- lines.append(f"│ │ → {call['code'].strip()}")
676
-
677
- if stack_info.get('lambda_context'):
678
- lines.append("│ │")
679
- lines.append("│ │ AWS Lambda Context:")
680
- lines.append("│ │ " + "─" * 19)
681
- for key, value in stack_info['lambda_context'].items():
682
- lines.append(f"│ │ {key}: {value}")
683
-
684
- lines.append("│ │")
685
- lines.append("│ └─────────────────────────────────────────────────────────")
686
-
687
- lines.append("│")
688
- lines.append("╰" + "─" * 70)
409
+ logger.error(
410
+ "Unhandled/Unknown Error in engine.process_error",
411
+ exc_info=True,
412
+ extra={
413
+ "error_code": error_code,
414
+ "error_msg": error_message,
415
+ "sql_stmt": sql,
416
+ "sql_params": parameters,
417
+ },
418
+ )
689
419
 
690
- return '\n'.join(lines)
420
+ # If we can't classify it, re-raise with enhanced message
421
+ raise type(exception)(enhanced_message) from exception
691
422
 
692
- def _format_email_error(self, error_class, message, original_exception, sql=None, parameters=None):
693
- """
694
- Format error message for email delivery with HTML formatting.
423
+ def _format_sql_with_params(self, sql, parameters):
695
424
  """
696
- # Map error classes to user-friendly descriptions
697
- error_descriptions = {
698
- 'DbColumnMissingError': 'Column Not Found',
699
- 'DbTableMissingError': 'Table Not Found',
700
- 'DbDatabaseMissingError': 'Database Not Found',
701
- 'DbForeignKeyMissingError': 'Foreign Key Constraint Violation',
702
- 'DbDuplicateKeyError': 'Duplicate Key Violation',
703
- 'DbConnectionError': 'Database Connection Failed',
704
- 'DbDataIntegrityError': 'Data Integrity Violation',
705
- 'DbQueryError': 'Query Execution Error',
706
- 'DbTransactionError': 'Transaction Error',
707
- 'DbTruncationError': 'Data Truncation Error',
708
- 'DatabaseError': 'Database Error'
709
- }
710
-
711
- # Get user-friendly error type
712
- friendly_type = error_descriptions.get(error_class, error_class.replace('Db', '').replace('Error', ' Error'))
713
-
714
- # Clean up the original message
715
- clean_message = str(message).strip()
716
-
717
- # Extract specific details from PostgreSQL errors
718
- details = self._extract_error_details(original_exception, clean_message)
719
-
720
- # Get error code
721
- error_code = getattr(original_exception, 'pgcode', None)
722
-
723
- # Get stack info
724
- stack_info = self._extract_call_stack_info()
725
-
726
- # Build HTML email format
727
- html_parts = []
728
-
729
- # Email header
730
- html_parts.append("""
731
- <html>
732
- <head>
733
- <style>
734
- body { font-family: 'Courier New', monospace; margin: 20px; }
735
- .error-container { border: 2px solid #dc3545; border-radius: 8px; padding: 20px; background-color: #f8f9fa; }
736
- .error-header { background-color: #dc3545; color: white; padding: 10px; border-radius: 5px; font-weight: bold; font-size: 16px; margin-bottom: 15px; }
737
- .error-section { margin: 15px 0; padding: 10px; background-color: #ffffff; border-left: 4px solid #007bff; }
738
- .section-title { font-weight: bold; color: #007bff; margin-bottom: 8px; }
739
- .code-block { background-color: #f1f3f4; padding: 10px; border-radius: 4px; font-family: 'Courier New', monospace; margin: 5px 0; white-space: pre-wrap; }
740
- .highlight { background-color: #fff3cd; padding: 2px 4px; border-radius: 3px; }
741
- .stack-call { margin: 5px 0; padding: 5px; background-color: #e9ecef; border-radius: 3px; }
742
- .copy-section { background-color: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin: 10px 0; }
743
- </style>
744
- </head>
745
- <body>
746
- <div class="error-container">
747
- """)
748
-
749
- # Error header
750
- html_parts.append(f' <div class="error-header">🚨 {friendly_type}</div>')
751
-
752
- # Main error description
753
- description = details.get('description', clean_message)
754
- html_parts.append(f' <div class="error-section"><strong>{description}</strong></div>')
755
-
756
- # Error details section
757
- if error_code or details.get('column') or details.get('table') or details.get('constraint'):
758
- html_parts.append(' <div class="error-section">')
759
- html_parts.append(' <div class="section-title">Error Details:</div>')
760
- if error_code:
761
- html_parts.append(f' <div><strong>Error Code:</strong> <span class="highlight">{error_code}</span></div>')
762
- if details.get('column'):
763
- html_parts.append(f' <div><strong>Column:</strong> <span class="highlight">{details["column"]}</span></div>')
764
- if details.get('table'):
765
- html_parts.append(f' <div><strong>Table:</strong> <span class="highlight">{details["table"]}</span></div>')
766
- if details.get('constraint'):
767
- html_parts.append(f' <div><strong>Constraint:</strong> <span class="highlight">{details["constraint"]}</span></div>')
768
- if details.get('hint'):
769
- html_parts.append(f' <div><strong>Hint:</strong> {details["hint"]}</div>')
770
- html_parts.append(' </div>')
771
-
772
- # SQL and Parameters section
773
- if sql or parameters is not None:
774
- html_parts.append(' <div class="copy-section">')
775
- html_parts.append(' <div class="section-title">📋 Debug Information (Copy-Paste Ready)</div>')
776
-
777
- if sql:
778
- html_parts.append(' <div><strong>SQL Statement:</strong></div>')
779
- html_parts.append(f' <div class="code-block">{self._html_escape(sql)}</div>')
780
-
781
- if parameters is not None:
782
- html_parts.append(f' <div><strong>Parameters:</strong> <code>{self._html_escape(str(parameters))}</code></div>')
783
-
784
- # Executable SQL
785
- if sql and parameters is not None:
786
- try:
787
- executable_sql = self._format_executable_sql(sql, parameters)
788
- html_parts.append(' <div><strong>Executable SQL (for testing):</strong></div>')
789
- html_parts.append(f' <div class="code-block">{self._html_escape(executable_sql)}</div>')
790
- except Exception:
791
- pass
792
-
793
- html_parts.append(' </div>')
794
-
795
- # Call stack section
796
- if stack_info and stack_info.get('top_level_call'):
797
- html_parts.append(' <div class="error-section">')
798
- html_parts.append(' <div class="section-title">🔍 Source Code Location</div>')
799
-
800
- call = stack_info['top_level_call']
801
- html_parts.append(' <div class="stack-call">')
802
- html_parts.append(f' <strong>Function:</strong> {call["function"]}<br>')
803
- html_parts.append(f' <strong>File:</strong> {call["file"]}<br>')
804
- html_parts.append(f' <strong>Line:</strong> {call["line"]}')
805
- if call.get('code'):
806
- html_parts.append(f'<br> <strong>Code:</strong> <code>{self._html_escape(call["code"].strip())}</code>')
807
- html_parts.append(' </div>')
808
-
809
- # Show relevant call chain
810
- if stack_info.get('relevant_calls') and len(stack_info['relevant_calls']) > 1:
811
- html_parts.append(' <div><strong>Call Chain:</strong></div>')
812
- for i, call in enumerate(stack_info['relevant_calls'][:4], 1):
813
- html_parts.append(' <div class="stack-call">')
814
- html_parts.append(f' {i}. <strong>{call["function"]}</strong> in {call["file"]}:{call["line"]}')
815
- html_parts.append(' </div>')
816
-
817
- html_parts.append(' </div>')
818
-
819
- # Lambda context
820
- if stack_info and stack_info.get('lambda_context'):
821
- html_parts.append(' <div class="error-section">')
822
- html_parts.append(' <div class="section-title">⚡ AWS Lambda Context</div>')
823
- for key, value in stack_info['lambda_context'].items():
824
- html_parts.append(f' <div><strong>{key.title()}:</strong> {value}</div>')
825
- html_parts.append(' </div>')
826
-
827
- # Email footer
828
- html_parts.append("""
829
- </div>
830
- </body>
831
- </html>
832
- """)
833
-
834
- return ''.join(html_parts)
835
-
836
- def _html_escape(self, text):
837
- """Escape HTML special characters."""
838
- if not text:
839
- return ""
840
- return (str(text)
841
- .replace('&', '&amp;')
842
- .replace('<', '&lt;')
843
- .replace('>', '&gt;')
844
- .replace('"', '&quot;')
845
- .replace("'", '&#x27;'))
846
-
847
- def _format_executable_sql(self, sql, parameters):
425
+ Format SQL query with parameters merged for easy copy-paste debugging.
848
426
  """
849
- Format SQL with parameters substituted for easy copy-paste debugging.
850
-
851
- Args:
852
- sql: The SQL statement with placeholders
853
- parameters: The parameters to substitute
427
+ if not sql:
428
+ return "No SQL provided"
854
429
 
855
- Returns:
856
- SQL statement with parameters properly formatted for execution
857
- """
858
430
  if not parameters:
859
431
  return sql
860
432
 
861
433
  try:
862
434
  # Handle different parameter formats
863
435
  if isinstance(parameters, (list, tuple)):
864
- # For positional parameters (%s style)
865
- formatted_sql = sql
866
-
867
- # Replace %s placeholders with properly formatted values
436
+ # Convert parameters to strings and handle None values
437
+ formatted_params = []
868
438
  for param in parameters:
869
- if isinstance(param, str):
439
+ if param is None:
440
+ formatted_params.append('NULL')
441
+ elif isinstance(param, str):
870
442
  # Escape single quotes and wrap in quotes
871
- formatted_param = f"'{param.replace(chr(39), chr(39)+chr(39))}'"
872
- elif isinstance(param, (int, float)):
873
- formatted_param = str(param)
874
- elif param is None:
875
- formatted_param = 'NULL'
443
+ escaped = param.replace("'", "''")
444
+ formatted_params.append(f"'{escaped}'")
876
445
  elif isinstance(param, bool):
877
- formatted_param = 'TRUE' if param else 'FALSE'
446
+ formatted_params.append('TRUE' if param else 'FALSE')
878
447
  else:
879
- # For other types, try to convert to string and quote
880
- formatted_param = f"'{str(param).replace(chr(39), chr(39)+chr(39))}'"
881
-
882
- # Replace first occurrence of %s
883
- formatted_sql = formatted_sql.replace('%s', formatted_param, 1)
448
+ formatted_params.append(str(param))
884
449
 
450
+ # Replace %s placeholders with actual values
451
+ formatted_sql = sql
452
+ for param in formatted_params:
453
+ formatted_sql = formatted_sql.replace('%s', param, 1)
454
+
885
455
  return formatted_sql
886
456
 
887
457
  elif isinstance(parameters, dict):
888
- # For named parameters (%(name)s style)
458
+ # Handle named parameters
889
459
  formatted_sql = sql
890
460
  for key, value in parameters.items():
891
- placeholder = f'%({key})s'
892
- if isinstance(value, str):
893
- formatted_value = f"'{value.replace(chr(39), chr(39)+chr(39))}'"
894
- elif isinstance(value, (int, float)):
895
- formatted_value = str(value)
896
- elif value is None:
897
- formatted_value = 'NULL'
461
+ if value is None:
462
+ replacement = 'NULL'
463
+ elif isinstance(value, str):
464
+ escaped = value.replace("'", "''")
465
+ replacement = f"'{escaped}'"
898
466
  elif isinstance(value, bool):
899
- formatted_value = 'TRUE' if value else 'FALSE'
467
+ replacement = 'TRUE' if value else 'FALSE'
900
468
  else:
901
- formatted_value = f"'{str(value).replace(chr(39), chr(39)+chr(39))}'"
469
+ replacement = str(value)
470
+
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)
902
474
 
903
- formatted_sql = formatted_sql.replace(placeholder, formatted_value)
904
-
905
475
  return formatted_sql
906
-
907
476
  else:
908
- # Fallback: just append parameters as comment
909
477
  return f"{sql}\n-- Parameters: {parameters}"
910
478
 
911
- except Exception:
912
- # If formatting fails, return original with parameters as comment
913
- return f"{sql}\n-- Parameters: {parameters}"
914
-
915
- def _extract_call_stack_info(self):
916
- """
917
- Extract relevant call stack information for debugging, filtering out
918
- middleware and focusing on the most useful frames.
919
-
920
- Returns:
921
- Dictionary with call stack analysis
922
- """
923
- import traceback
924
- import os
925
-
926
- try:
927
- # Get the current stack
928
- stack = traceback.extract_stack()
929
-
930
- # Common middleware/decorator patterns to filter out
931
- middleware_patterns = [
932
- 'decorator',
933
- 'wrapper',
934
- 'new_function',
935
- 'exec_function',
936
- '_execute',
937
- 'process_error',
938
- '_create_exception',
939
- '_format_human_readable',
940
- '__enter__',
941
- '__exit__',
942
- 'contextlib',
943
- 'functools'
944
- ]
945
-
946
- # Lambda/AWS specific patterns
947
- lambda_patterns = [
948
- 'lambda_handler',
949
- 'handler',
950
- 'bootstrap',
951
- 'runtime'
952
- ]
953
-
954
- relevant_calls = []
955
- top_level_call = None
956
- lambda_context = {}
957
-
958
- # Analyze stack frames from top to bottom (most recent first)
959
- for frame in reversed(stack[:-4]): # Skip the last few frames (this method, etc.)
960
- file_path = frame.filename
961
- function_name = frame.name
962
- line_number = frame.lineno
963
- code_line = frame.line or ""
964
-
965
- # Extract just the filename
966
- filename = os.path.basename(file_path)
967
-
968
- # Skip internal Python and library frames
969
- if any(skip in file_path.lower() for skip in [
970
- 'python3', 'site-packages', '/usr/', '/opt/python',
971
- 'psycopg2', 'boto3', 'botocore'
972
- ]):
973
- continue
974
-
975
- # Capture Lambda context if found
976
- if any(pattern in function_name.lower() for pattern in lambda_patterns):
977
- lambda_context.update({
978
- 'handler': function_name,
979
- 'file': filename,
980
- 'line': line_number
981
- })
982
-
983
- # Skip middleware/decorator frames but keep track of meaningful ones
984
- is_middleware = any(pattern in function_name.lower() for pattern in middleware_patterns)
985
-
986
- if not is_middleware:
987
- call_info = {
988
- 'function': function_name,
989
- 'file': filename,
990
- 'line': line_number,
991
- 'code': code_line,
992
- 'full_path': file_path
993
- }
994
-
995
- relevant_calls.append(call_info)
996
-
997
- # The first non-middleware call is likely the most important
998
- if not top_level_call:
999
- # Look for application-level calls (not in velocity internals)
1000
- if not any(internal in file_path for internal in [
1001
- 'velocity/db/', 'velocity/aws/', 'velocity/misc/'
1002
- ]):
1003
- top_level_call = call_info
1004
-
1005
- # If no clear top-level call found, use the first relevant call
1006
- if not top_level_call and relevant_calls:
1007
- top_level_call = relevant_calls[0]
1008
-
1009
- # Look for handler functions in the stack
1010
- handler_calls = [call for call in relevant_calls
1011
- if any(pattern in call['function'].lower()
1012
- for pattern in ['handler', 'main', 'process', 'action'])]
1013
-
1014
- if handler_calls and not top_level_call:
1015
- top_level_call = handler_calls[0]
1016
-
1017
- return {
1018
- 'top_level_call': top_level_call,
1019
- 'relevant_calls': relevant_calls,
1020
- 'lambda_context': lambda_context if lambda_context else None
1021
- }
1022
-
1023
- except Exception:
1024
- # If stack analysis fails, return minimal info
1025
- return None
1026
-
1027
- def _extract_error_details(self, exception, message):
1028
- """
1029
- Extract specific details from database errors for better formatting.
1030
-
1031
- Args:
1032
- exception: The original database exception
1033
- message: The error message string
1034
-
1035
- Returns:
1036
- Dictionary with extracted details
1037
- """
1038
- import re
1039
-
1040
- details = {}
1041
-
1042
- # PostgreSQL specific error parsing
1043
- if hasattr(exception, 'pgcode'):
1044
- # Column does not exist
1045
- if 'column' in message.lower() and 'does not exist' in message.lower():
1046
- match = re.search(r'column "([^"]+)" does not exist', message, re.IGNORECASE)
1047
- if match:
1048
- details['column'] = match.group(1)
1049
- details['description'] = f'The column "{match.group(1)}" was not found in the table.'
1050
-
1051
- # Table does not exist
1052
- elif 'relation' in message.lower() and 'does not exist' in message.lower():
1053
- match = re.search(r'relation "([^"]+)" does not exist', message, re.IGNORECASE)
1054
- if match:
1055
- details['table'] = match.group(1)
1056
- details['description'] = f'The table "{match.group(1)}" was not found in the database.'
1057
-
1058
- # Foreign key violation
1059
- elif 'foreign key constraint' in message.lower():
1060
- match = re.search(r'violates foreign key constraint "([^"]+)"', message, re.IGNORECASE)
1061
- if match:
1062
- details['constraint'] = match.group(1)
1063
- details['description'] = 'A foreign key constraint was violated.'
1064
- details['hint'] = 'Make sure the referenced record exists.'
1065
-
1066
- # Unique constraint violation
1067
- elif 'unique constraint' in message.lower() or 'duplicate key' in message.lower():
1068
- match = re.search(r'violates unique constraint "([^"]+)"', message, re.IGNORECASE)
1069
- if match:
1070
- details['constraint'] = match.group(1)
1071
- details['description'] = 'A unique constraint was violated (duplicate key).'
1072
- details['hint'] = 'The value you are trying to insert already exists.'
1073
-
1074
- # Connection errors
1075
- elif any(term in message.lower() for term in ['connection', 'connect', 'server']):
1076
- details['description'] = 'Failed to connect to the database server.'
1077
- details['hint'] = 'Check your database connection settings and network connectivity.'
1078
-
1079
- # Data type errors
1080
- elif 'invalid input syntax' in message.lower():
1081
- details['description'] = 'Invalid data format provided.'
1082
- details['hint'] = 'Check that your data matches the expected format for the column type.'
1083
-
1084
- return details
1085
-
1086
- def _create_exception_with_chaining(self, error_class, message, original_exception, sql=None, parameters=None, format_type=None):
1087
- """
1088
- Create a velocity exception with proper exception chaining and human-readable formatting.
1089
-
1090
- Args:
1091
- error_class: The name of the exception class to create
1092
- message: The error message
1093
- original_exception: The original exception to chain
1094
- sql: The SQL statement (optional)
1095
- parameters: The SQL parameters (optional)
1096
- format_type: 'console', 'email', or None (auto-detect)
1097
-
1098
- Returns:
1099
- The created exception with proper chaining and formatting
1100
- """
1101
- logger = logging.getLogger(__name__)
1102
-
1103
- try:
1104
- # Import the exception class dynamically
1105
- exception_module = __import__('velocity.db.exceptions', fromlist=[error_class])
1106
- ExceptionClass = getattr(exception_module, error_class)
1107
-
1108
- # Auto-detect format if not specified
1109
- if format_type is None:
1110
- format_type = self._detect_output_format()
1111
-
1112
- # Create human-readable, formatted message
1113
- formatted_message = self._format_human_readable_error(
1114
- error_class, message, original_exception, sql, parameters, format_type
1115
- )
1116
-
1117
- # For email format, also create a console version for logging
1118
- if format_type == 'email':
1119
- console_message = self._format_human_readable_error(
1120
- error_class, message, original_exception, sql, parameters, 'console'
1121
- )
1122
- # Log the console version for server logs
1123
- logger.error(f"Database Error (Console Format):\n{console_message}")
1124
-
1125
- # Create custom exception with both formats
1126
- new_exception = ExceptionClass(formatted_message)
1127
- new_exception.console_format = console_message
1128
- new_exception.email_format = formatted_message
1129
- else:
1130
- new_exception = ExceptionClass(formatted_message)
1131
-
1132
- # Only set __cause__ if original_exception is not None and derives from BaseException
1133
- if isinstance(original_exception, BaseException):
1134
- new_exception.__cause__ = original_exception # Preserve exception chain
1135
-
1136
- return new_exception
1137
-
1138
- except (ImportError, AttributeError) as e:
1139
- logger.error(f"Could not import exception class {error_class}: {e}")
1140
- # Fallback to generic database error
1141
- try:
1142
- exception_module = __import__('velocity.db.exceptions', fromlist=['DatabaseError'])
1143
- DatabaseError = getattr(exception_module, 'DatabaseError')
1144
-
1145
- # Auto-detect format if not specified for fallback too
1146
- if format_type is None:
1147
- format_type = self._detect_output_format()
1148
-
1149
- # Still format the fallback nicely
1150
- formatted_message = self._format_human_readable_error(
1151
- 'DatabaseError', message, original_exception, sql, parameters, format_type
1152
- )
1153
-
1154
- fallback_exception = DatabaseError(formatted_message)
1155
- # Only set __cause__ if original_exception is not None and derives from BaseException
1156
- if isinstance(original_exception, BaseException):
1157
- fallback_exception.__cause__ = original_exception
1158
- return fallback_exception
1159
- except Exception as fallback_error:
1160
- logger.critical(f"Failed to create fallback exception: {fallback_error}")
1161
- # Last resort: return the original exception
1162
- return original_exception
1163
-
1164
- def _detect_output_format(self):
1165
- """
1166
- Detect whether we should use console or email formatting based on context.
1167
-
1168
- Returns:
1169
- 'email' if in email/notification context, 'console' otherwise
1170
- """
1171
- import inspect
1172
-
1173
- # Look at the call stack for email-related functions
1174
- stack = inspect.stack()
1175
-
1176
- email_indicators = [
1177
- 'email', 'mail', 'notification', 'alert', 'send', 'notify',
1178
- 'smtp', 'message', 'recipient', 'subject', 'body'
1179
- ]
1180
-
1181
- for frame_info in stack:
1182
- function_name = frame_info.function.lower()
1183
- filename = frame_info.filename.lower()
1184
-
1185
- # Check if we're in an email-related context
1186
- if any(indicator in function_name or indicator in filename
1187
- for indicator in email_indicators):
1188
- return 'email'
1189
-
1190
- # Default to console format
1191
- return 'console'
479
+ except Exception as e:
480
+ # If formatting fails, return original SQL with parameters shown separately
481
+ return f"{sql}\n-- Parameters (formatting failed): {parameters}\n-- Error: {e}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.109
3
+ Version: 0.0.112
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
@@ -1,4 +1,4 @@
1
- velocity/__init__.py,sha256=draltUZCn7wvAd-GzY0ByKEFFyPY41-ilFTXKqfVs3M,107
1
+ velocity/__init__.py,sha256=iH2m31ZNvp-oPV3zuzJRMhEbgHm_d-Gs5k6ljPudBlg,107
2
2
  velocity/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  velocity/app/invoices.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  velocity/app/orders.py,sha256=W-HAXEwY8-IFXbKh82HnMeRVZM7P-TWGEQOWtkLIzI4,6298
@@ -18,7 +18,7 @@ velocity/db/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
18
18
  velocity/db/core/column.py,sha256=tAr8tL3a2nyaYpNHhGl508FrY_pGZTzyYgjAV5CEBv4,4092
19
19
  velocity/db/core/database.py,sha256=3zNGItklu9tZCKsbx2T2vCcU1so8AL9PPL0DLjvaz6s,3554
20
20
  velocity/db/core/decorators.py,sha256=76Jkr9XptXt8cvcgp1zbHfuL8uHzWy8lwfR29u-DVu4,4574
21
- velocity/db/core/engine.py,sha256=_9ccyKs1fK1Kd41vnTsApZQwsEn6WS0l91ZhUzCRfxE,51459
21
+ velocity/db/core/engine.py,sha256=Yy6uwxT44a4oEHrFF312ChzFO01cR2W79BTryFgTe-s,18635
22
22
  velocity/db/core/exceptions.py,sha256=tuDniRqTX8Opc2d033LPJOI3Ux4NSwUcHqW729n-HXA,1027
23
23
  velocity/db/core/result.py,sha256=dgiOXH-iJXuDH4PbSTWVkn-heAkJQcXCC-gs0ZuqF94,12814
24
24
  velocity/db/core/row.py,sha256=zZ3zZbWjZkZfYAYuZJLHFJ8jdXc7dYv8Iyv9Ut8W8tE,7261
@@ -49,8 +49,8 @@ velocity/misc/tools.py,sha256=_bGneHHA_BV-kUonzw5H3hdJ5AOJRCKfzhgpkFbGqIo,1502
49
49
  velocity/misc/conv/__init__.py,sha256=MLYF58QHjzfDSxb1rdnmLnuEQCa3gnhzzZ30CwZVvQo,40
50
50
  velocity/misc/conv/iconv.py,sha256=d4_BucW8HTIkGNurJ7GWrtuptqUf-9t79ObzjJ5N76U,10603
51
51
  velocity/misc/conv/oconv.py,sha256=h5Lo05DqOQnxoD3y6Px_MQP_V-pBbWf8Hkgkb9Xp1jk,6032
52
- velocity_python-0.0.109.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
53
- velocity_python-0.0.109.dist-info/METADATA,sha256=PTKOVMB4FXBuW1uj5d6xEPvH2H5j72ZhD_PYHUQOJs0,34262
54
- velocity_python-0.0.109.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
55
- velocity_python-0.0.109.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
56
- velocity_python-0.0.109.dist-info/RECORD,,
52
+ velocity_python-0.0.112.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
53
+ velocity_python-0.0.112.dist-info/METADATA,sha256=LEPElQOJU4yBn0Zwdw4VzpLRNhC4M6MM1_6Qa28VBDU,34262
54
+ velocity_python-0.0.112.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
55
+ velocity_python-0.0.112.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
56
+ velocity_python-0.0.112.dist-info/RECORD,,