fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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.
Files changed (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1893 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +184 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +882 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,479 @@
1
+ """Performance profiling system for database migrations.
2
+
3
+ Provides detailed performance metrics and regression detection for migrations.
4
+
5
+ Architecture:
6
+ - MigrationPerformanceProfiler: Profiles migration execution with detailed metrics
7
+ - PerformanceProfile: Detailed metrics for a single migration
8
+ - PerformanceBaseline: Reference metrics for regression detection
9
+ - PerformanceOptimizationReport: Bottleneck identification and recommendations
10
+ """
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import psycopg
19
+
20
+
21
+ @dataclass
22
+ class OperationMetrics:
23
+ """Metrics for a single operation."""
24
+
25
+ name: str # Operation name (e.g., "ALTER TABLE")
26
+ start_time: float # Timestamp when operation started
27
+ end_time: float # Timestamp when operation ended
28
+ duration_seconds: float # Total duration in seconds
29
+ percent_of_total: float # Percentage of migration time
30
+ memory_before_mb: float | None # Memory before operation (if tracked)
31
+ memory_after_mb: float | None # Memory after operation (if tracked)
32
+ io_operations: int | None # Number of I/O operations (if tracked)
33
+
34
+ @property
35
+ def memory_delta_mb(self) -> float | None:
36
+ """Calculate memory change during operation."""
37
+ if self.memory_before_mb is not None and self.memory_after_mb is not None:
38
+ return self.memory_after_mb - self.memory_before_mb
39
+ return None
40
+
41
+
42
+ @dataclass
43
+ class PerformanceProfile:
44
+ """Performance profile for a migration execution."""
45
+
46
+ migration_name: str
47
+ start_timestamp: float
48
+ end_timestamp: float
49
+ total_duration_seconds: float
50
+
51
+ operations: dict[str, OperationMetrics] = field(default_factory=dict)
52
+ memory_peak_mb: float | None = None
53
+ cpu_avg_percent: float | None = None
54
+ total_io_operations: int | None = None
55
+
56
+ def get_bottlenecks(self, threshold: float = 0.05) -> list[OperationMetrics]:
57
+ """Get operations consuming more than threshold of total time.
58
+
59
+ Args:
60
+ threshold: Percentage threshold (e.g., 0.05 for 5%)
61
+
62
+ Returns:
63
+ List of bottleneck operations sorted by duration descending
64
+ """
65
+ bottlenecks = [
66
+ op for op in self.operations.values() if op.percent_of_total >= (threshold * 100)
67
+ ]
68
+ return sorted(bottlenecks, key=lambda x: x.duration_seconds, reverse=True)
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ """Convert profile to dictionary for JSON serialization."""
72
+ return {
73
+ "migration_name": self.migration_name,
74
+ "total_duration_seconds": self.total_duration_seconds,
75
+ "memory_peak_mb": self.memory_peak_mb,
76
+ "cpu_avg_percent": self.cpu_avg_percent,
77
+ "total_io_operations": self.total_io_operations,
78
+ "operations": [asdict(op) for op in self.operations.values()],
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class RegressionReport:
84
+ """Report of performance regressions detected."""
85
+
86
+ migration_name: str
87
+ regressions: list[dict[str, Any]] = field(default_factory=list)
88
+
89
+ @property
90
+ def has_regressions(self) -> bool:
91
+ """Whether any regressions were detected."""
92
+ return len(self.regressions) > 0
93
+
94
+ @property
95
+ def worst_regression_pct(self) -> float:
96
+ """Worst regression percentage if any."""
97
+ if not self.regressions:
98
+ return 0.0
99
+ return max(r["regression_pct"] for r in self.regressions)
100
+
101
+
102
+ @dataclass
103
+ class PerformanceOptimizationRecommendation:
104
+ """A recommendation for performance optimization."""
105
+
106
+ operation: str
107
+ current_duration_seconds: float
108
+ percent_of_total: float
109
+ severity: str # "CRITICAL", "IMPORTANT", "MINOR"
110
+ recommendation: str
111
+ potential_speedup: str # e.g., "2-3x"
112
+
113
+
114
+ @dataclass
115
+ class PerformanceOptimizationReport:
116
+ """Report with optimization recommendations."""
117
+
118
+ migration_name: str
119
+ bottlenecks: list[OperationMetrics]
120
+ recommendations: list[PerformanceOptimizationRecommendation] = field(default_factory=list)
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Convert to dictionary."""
124
+ return {
125
+ "migration_name": self.migration_name,
126
+ "bottleneck_count": len(self.bottlenecks),
127
+ "recommendations": [asdict(r) for r in self.recommendations],
128
+ }
129
+
130
+
131
+ class MigrationPerformanceProfiler:
132
+ """Profile database migration performance."""
133
+
134
+ def __init__(self, db_connection: psycopg.Connection):
135
+ self.connection = db_connection
136
+ self.current_profile: PerformanceProfile | None = None
137
+ self.section_stack: list[tuple[str, float]] = []
138
+
139
+ def profile_migration(self, migration_name: str, execute_fn) -> PerformanceProfile:
140
+ """Profile migration execution.
141
+
142
+ Args:
143
+ migration_name: Name of the migration
144
+ execute_fn: Function to execute (receives profiler as argument)
145
+
146
+ Returns:
147
+ PerformanceProfile with detailed metrics
148
+ """
149
+ start_time = time.time()
150
+
151
+ self.current_profile = PerformanceProfile(
152
+ migration_name=migration_name,
153
+ start_timestamp=start_time,
154
+ end_timestamp=0.0,
155
+ total_duration_seconds=0.0,
156
+ )
157
+
158
+ try:
159
+ # Execute migration with profiling
160
+ execute_fn(self)
161
+ finally:
162
+ end_time = time.time()
163
+ self.current_profile.end_timestamp = end_time
164
+ self.current_profile.total_duration_seconds = end_time - start_time
165
+
166
+ # Finalize operation metrics
167
+ self._finalize_operations()
168
+
169
+ return self.current_profile
170
+
171
+ def track_section(self, section_name: str):
172
+ """Context manager for tracking operation duration.
173
+
174
+ Usage:
175
+ with profiler.track_section("operation_name"):
176
+ # Do work
177
+ pass
178
+ """
179
+ return _SectionTracker(self, section_name)
180
+
181
+ def record_operation(
182
+ self,
183
+ name: str,
184
+ duration_seconds: float,
185
+ memory_before_mb: float | None = None,
186
+ memory_after_mb: float | None = None,
187
+ io_operations: int | None = None,
188
+ ):
189
+ """Record an operation's metrics.
190
+
191
+ Args:
192
+ name: Operation name
193
+ duration_seconds: Operation duration
194
+ memory_before_mb: Memory before (optional)
195
+ memory_after_mb: Memory after (optional)
196
+ io_operations: Number of I/O ops (optional)
197
+ """
198
+ if self.current_profile is None:
199
+ return
200
+
201
+ metrics = OperationMetrics(
202
+ name=name,
203
+ start_time=time.time(),
204
+ end_time=time.time() + duration_seconds,
205
+ duration_seconds=duration_seconds,
206
+ percent_of_total=0.0, # Will be calculated later
207
+ memory_before_mb=memory_before_mb,
208
+ memory_after_mb=memory_after_mb,
209
+ io_operations=io_operations,
210
+ )
211
+
212
+ self.current_profile.operations[name] = metrics
213
+
214
+ def _finalize_operations(self):
215
+ """Calculate percentages and finalize operation metrics."""
216
+ if self.current_profile is None:
217
+ return
218
+
219
+ total = self.current_profile.total_duration_seconds
220
+ if total <= 0:
221
+ return
222
+
223
+ for operation in self.current_profile.operations.values():
224
+ operation.percent_of_total = (operation.duration_seconds / total) * 100
225
+
226
+ def get_profile(self) -> PerformanceProfile | None:
227
+ """Get current profile."""
228
+ return self.current_profile
229
+
230
+
231
+ class _SectionTracker:
232
+ """Context manager for tracking operation sections."""
233
+
234
+ def __init__(self, profiler: MigrationPerformanceProfiler, section_name: str):
235
+ self.profiler = profiler
236
+ self.section_name = section_name
237
+ self.start_time = 0.0
238
+ self.memory_before_mb: float | None = None
239
+ self.memory_after_mb: float | None = None
240
+
241
+ def __enter__(self):
242
+ self.start_time = time.time()
243
+ self.memory_before_mb = self._get_memory_usage_mb()
244
+ return self
245
+
246
+ def __exit__(self, exc_type, exc_val, exc_tb):
247
+ end_time = time.time()
248
+ duration = end_time - self.start_time
249
+ self.memory_after_mb = self._get_memory_usage_mb()
250
+
251
+ self.profiler.record_operation(
252
+ name=self.section_name,
253
+ duration_seconds=duration,
254
+ memory_before_mb=self.memory_before_mb,
255
+ memory_after_mb=self.memory_after_mb,
256
+ )
257
+
258
+ def _get_memory_usage_mb(self) -> float | None:
259
+ """Get current memory usage (best effort)."""
260
+ try:
261
+ import psutil # type: ignore[import-untyped]
262
+
263
+ process = psutil.Process()
264
+ return process.memory_info().rss / 1024 / 1024
265
+ except ImportError:
266
+ return None
267
+
268
+
269
+ class PerformanceBaseline:
270
+ """Baseline performance metrics for regression detection."""
271
+
272
+ def __init__(self, baselines_file: Path):
273
+ self.baselines_file = baselines_file
274
+ self.baselines: dict[str, dict[str, Any]] = {}
275
+ self._load_baselines()
276
+
277
+ def _load_baselines(self):
278
+ """Load baseline metrics from file."""
279
+ if self.baselines_file.exists():
280
+ with open(self.baselines_file) as f:
281
+ data = json.load(f)
282
+ self.baselines = data.get("baselines", {})
283
+
284
+ def save_baselines(self):
285
+ """Save baseline metrics to file."""
286
+ data = {"baselines": self.baselines}
287
+ self.baselines_file.parent.mkdir(parents=True, exist_ok=True)
288
+ with open(self.baselines_file, "w") as f:
289
+ json.dump(data, f, indent=2)
290
+
291
+ def set_baseline(self, migration_name: str, profile: PerformanceProfile):
292
+ """Set baseline for a migration."""
293
+ self.baselines[migration_name] = {
294
+ "total_duration_seconds": profile.total_duration_seconds,
295
+ "memory_peak_mb": profile.memory_peak_mb or 0.0,
296
+ "operations": {name: op.duration_seconds for name, op in profile.operations.items()},
297
+ }
298
+
299
+ def detect_regression(
300
+ self,
301
+ current_profile: PerformanceProfile,
302
+ threshold_pct: float = 20.0,
303
+ ) -> RegressionReport:
304
+ """Detect performance regressions.
305
+
306
+ Args:
307
+ current_profile: Current performance profile
308
+ threshold_pct: Regression threshold percentage (default 20%)
309
+
310
+ Returns:
311
+ RegressionReport with detected regressions
312
+ """
313
+ report = RegressionReport(migration_name=current_profile.migration_name)
314
+
315
+ baseline = self.baselines.get(current_profile.migration_name)
316
+ if not baseline:
317
+ # No baseline to compare against
318
+ return report
319
+
320
+ # Check total duration regression
321
+ baseline_total = baseline["total_duration_seconds"]
322
+ current_total = current_profile.total_duration_seconds
323
+
324
+ if current_total > baseline_total * (1.0 + threshold_pct / 100.0):
325
+ regression_pct = ((current_total / baseline_total) - 1.0) * 100
326
+ report.regressions.append(
327
+ {
328
+ "type": "total_duration",
329
+ "operation": "Overall migration",
330
+ "baseline": baseline_total,
331
+ "current": current_total,
332
+ "regression_pct": regression_pct,
333
+ }
334
+ )
335
+
336
+ # Check individual operation regressions
337
+ baseline_ops = baseline.get("operations", {})
338
+ for op_name, current_duration in current_profile.operations.items():
339
+ baseline_duration = baseline_ops.get(op_name)
340
+ if baseline_duration is None:
341
+ continue
342
+
343
+ if current_duration.duration_seconds > baseline_duration * (
344
+ 1.0 + threshold_pct / 100.0
345
+ ):
346
+ regression_pct = (
347
+ (current_duration.duration_seconds / baseline_duration) - 1.0
348
+ ) * 100
349
+ report.regressions.append(
350
+ {
351
+ "type": "operation_duration",
352
+ "operation": op_name,
353
+ "baseline": baseline_duration,
354
+ "current": current_duration.duration_seconds,
355
+ "regression_pct": regression_pct,
356
+ }
357
+ )
358
+
359
+ return report
360
+
361
+ def generate_optimization_report(
362
+ self,
363
+ profile: PerformanceProfile,
364
+ ) -> PerformanceOptimizationReport:
365
+ """Generate optimization recommendations based on profile.
366
+
367
+ Args:
368
+ profile: Performance profile to analyze
369
+
370
+ Returns:
371
+ PerformanceOptimizationReport with recommendations
372
+ """
373
+ bottlenecks = profile.get_bottlenecks(threshold=0.05)
374
+ report = PerformanceOptimizationReport(
375
+ migration_name=profile.migration_name,
376
+ bottlenecks=bottlenecks,
377
+ )
378
+
379
+ # Generate recommendations for each bottleneck
380
+ for bottleneck in bottlenecks:
381
+ recommendation = self._generate_recommendation(bottleneck, profile)
382
+ if recommendation:
383
+ report.recommendations.append(recommendation)
384
+
385
+ return report
386
+
387
+ def _generate_recommendation(
388
+ self,
389
+ bottleneck: OperationMetrics,
390
+ _profile: PerformanceProfile,
391
+ ) -> PerformanceOptimizationRecommendation | None:
392
+ """Generate optimization recommendation for a bottleneck."""
393
+ operation_type = self._extract_operation_type(bottleneck.name)
394
+
395
+ if operation_type == "UPDATE" and bottleneck.duration_seconds > 0.01:
396
+ return PerformanceOptimizationRecommendation(
397
+ operation=bottleneck.name,
398
+ current_duration_seconds=bottleneck.duration_seconds,
399
+ percent_of_total=bottleneck.percent_of_total,
400
+ severity="CRITICAL" if bottleneck.percent_of_total > 50 else "IMPORTANT",
401
+ recommendation=(
402
+ "UPDATE operation is slow. Consider:\n"
403
+ " - Use bulk update with WHERE clause\n"
404
+ " - Add index on filter columns\n"
405
+ " - Batch processing with LIMIT\n"
406
+ " - Analyze query plan with EXPLAIN"
407
+ ),
408
+ potential_speedup="2-5x",
409
+ )
410
+
411
+ elif operation_type == "INSERT" and bottleneck.duration_seconds > 0.01:
412
+ return PerformanceOptimizationRecommendation(
413
+ operation=bottleneck.name,
414
+ current_duration_seconds=bottleneck.duration_seconds,
415
+ percent_of_total=bottleneck.percent_of_total,
416
+ severity="IMPORTANT",
417
+ recommendation=(
418
+ "INSERT operation is slow. Consider:\n"
419
+ " - Use COPY command for bulk insert\n"
420
+ " - Disable triggers during insert\n"
421
+ " - Increase work_mem for sort operations\n"
422
+ " - Batch insert in smaller chunks"
423
+ ),
424
+ potential_speedup="3-10x",
425
+ )
426
+
427
+ elif operation_type == "INDEX" and bottleneck.duration_seconds > 0.01:
428
+ return PerformanceOptimizationRecommendation(
429
+ operation=bottleneck.name,
430
+ current_duration_seconds=bottleneck.duration_seconds,
431
+ percent_of_total=bottleneck.percent_of_total,
432
+ severity="IMPORTANT",
433
+ recommendation=(
434
+ "Index creation is slow. Consider:\n"
435
+ " - Create index CONCURRENTLY\n"
436
+ " - Use FILLFACTOR for indexes on volatile tables\n"
437
+ " - Create in parallel on replicas first\n"
438
+ " - Consider partial index if possible"
439
+ ),
440
+ potential_speedup="1.5-3x",
441
+ )
442
+
443
+ return None
444
+
445
+ def _extract_operation_type(self, operation_name: str) -> str:
446
+ """Extract operation type from operation name."""
447
+ name_upper = operation_name.upper()
448
+
449
+ for op_type in ["UPDATE", "INSERT", "DELETE", "ALTER", "CREATE", "INDEX"]:
450
+ if op_type in name_upper:
451
+ return op_type
452
+
453
+ return "UNKNOWN"
454
+
455
+ def export_baseline(self, path: Path):
456
+ """Export baselines to file."""
457
+ data = {"baselines": self.baselines}
458
+ path.parent.mkdir(parents=True, exist_ok=True)
459
+ with open(path, "w") as f:
460
+ json.dump(data, f, indent=2)
461
+
462
+ def export_comparison(self, profile: PerformanceProfile, path: Path):
463
+ """Export comparison with baseline."""
464
+ regression = self.detect_regression(profile)
465
+ optimization = self.generate_optimization_report(profile)
466
+
467
+ comparison = {
468
+ "migration": profile.migration_name,
469
+ "profile": profile.to_dict(),
470
+ "regression": {
471
+ "has_regressions": regression.has_regressions,
472
+ "regressions": regression.regressions,
473
+ },
474
+ "optimization": optimization.to_dict(),
475
+ }
476
+
477
+ path.parent.mkdir(parents=True, exist_ok=True)
478
+ with open(path, "w") as f:
479
+ json.dump(comparison, f, indent=2)
@@ -0,0 +1,225 @@
1
+ """Migration loader utility for testing.
2
+
3
+ Provides a simple API for loading migration classes without the boilerplate
4
+ of manual importlib usage.
5
+
6
+ Example:
7
+ >>> from confiture.testing import load_migration
8
+ >>> Migration003 = load_migration("003_move_catalog_tables")
9
+ >>> Migration003 = load_migration(version="003")
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ from confiture.core.connection import get_migration_class, load_migration_module
15
+ from confiture.exceptions import MigrationError
16
+ from confiture.models.migration import Migration
17
+ from confiture.models.sql_file_migration import FileSQLMigration
18
+
19
+
20
+ class MigrationNotFoundError(MigrationError):
21
+ """Raised when a migration file cannot be found."""
22
+
23
+ pass
24
+
25
+
26
+ class MigrationLoadError(MigrationError):
27
+ """Raised when a migration cannot be loaded from file."""
28
+
29
+ pass
30
+
31
+
32
+ def load_migration(
33
+ name: str | None = None,
34
+ *,
35
+ version: str | None = None,
36
+ migrations_dir: Path | None = None,
37
+ ) -> type[Migration]:
38
+ """Load a migration class by name or version.
39
+
40
+ This function provides a convenient way to load migration classes for
41
+ testing without the boilerplate of manual importlib usage. It supports
42
+ both Python migrations (.py) and SQL-only migrations (.up.sql/.down.sql).
43
+
44
+ Args:
45
+ name: Migration filename without extension
46
+ (e.g., "003_move_catalog_tables")
47
+ version: Migration version prefix (e.g., "003"). If provided,
48
+ searches for any migration starting with this version.
49
+ migrations_dir: Custom migrations directory. Defaults to "db/migrations"
50
+ relative to current working directory.
51
+
52
+ Returns:
53
+ The Migration class (not an instance). You can instantiate it with
54
+ a connection: `migration = MigrationClass(connection=conn)`
55
+
56
+ Raises:
57
+ MigrationNotFoundError: If no migration file matches the name/version
58
+ MigrationLoadError: If the migration file cannot be loaded
59
+ ValueError: If neither name nor version is provided, or both are provided
60
+
61
+ Example:
62
+ Load Python migration by full name:
63
+ >>> Migration003 = load_migration("003_move_catalog_tables")
64
+ >>> migration = Migration003(connection=conn)
65
+ >>> migration.up()
66
+
67
+ Load SQL-only migration (automatically detected):
68
+ >>> Migration004 = load_migration("004_add_indexes")
69
+ >>> # This works if 004_add_indexes.up.sql and .down.sql exist
70
+
71
+ Load by version prefix:
72
+ >>> Migration = load_migration(version="003")
73
+
74
+ Load from custom directory:
75
+ >>> Migration = load_migration("003_test", migrations_dir=Path("/tmp/migrations"))
76
+ """
77
+ # Validate arguments
78
+ if name is None and version is None:
79
+ raise ValueError("Either 'name' or 'version' must be provided")
80
+ if name is not None and version is not None:
81
+ raise ValueError("Provide either 'name' or 'version', not both")
82
+
83
+ # Determine migrations directory
84
+ if migrations_dir is None:
85
+ migrations_dir = Path("db/migrations")
86
+
87
+ if not migrations_dir.exists():
88
+ raise MigrationNotFoundError(f"Migrations directory not found: {migrations_dir.absolute()}")
89
+
90
+ # Find the migration file
91
+ if name is not None:
92
+ return _load_by_name(name, migrations_dir)
93
+ else:
94
+ assert version is not None # For type checker
95
+ return _load_by_version(version, migrations_dir)
96
+
97
+
98
+ def _load_by_name(name: str, migrations_dir: Path) -> type[Migration]:
99
+ """Load migration by exact name, trying Python first then SQL."""
100
+ # Try Python migration first
101
+ py_file = migrations_dir / f"{name}.py"
102
+ if py_file.exists():
103
+ return _load_python_migration(py_file)
104
+
105
+ # Try SQL-only migration
106
+ up_file = migrations_dir / f"{name}.up.sql"
107
+ down_file = migrations_dir / f"{name}.down.sql"
108
+
109
+ if up_file.exists() and down_file.exists():
110
+ return FileSQLMigration.from_files(up_file, down_file)
111
+
112
+ # Neither found - provide helpful error message
113
+ if up_file.exists() and not down_file.exists():
114
+ raise MigrationNotFoundError(
115
+ f"SQL migration found but missing .down.sql file.\n"
116
+ f"Found: {up_file}\n"
117
+ f"Missing: {down_file}\n"
118
+ f"Hint: Create {down_file.name} with the rollback SQL"
119
+ )
120
+
121
+ raise MigrationNotFoundError(
122
+ f"Migration not found: {name}\n"
123
+ f"Searched for:\n"
124
+ f" - {py_file} (Python migration)\n"
125
+ f" - {up_file} + {down_file} (SQL-only migration)\n"
126
+ f"Hint: Make sure the migration files exist in {migrations_dir}"
127
+ )
128
+
129
+
130
+ def _load_by_version(version: str, migrations_dir: Path) -> type[Migration]:
131
+ """Load migration by version prefix, trying Python first then SQL."""
132
+ # Find Python migrations
133
+ py_files = list(migrations_dir.glob(f"{version}_*.py"))
134
+
135
+ # Find SQL-only migrations
136
+ sql_up_files = list(migrations_dir.glob(f"{version}_*.up.sql"))
137
+
138
+ # Collect all matches
139
+ all_matches: list[tuple[str, Path]] = []
140
+ for f in py_files:
141
+ all_matches.append(("python", f))
142
+ for up_f in sql_up_files:
143
+ # Check that .down.sql exists
144
+ base_name = up_f.name.replace(".up.sql", "")
145
+ down_f = migrations_dir / f"{base_name}.down.sql"
146
+ if down_f.exists():
147
+ all_matches.append(("sql", up_f))
148
+
149
+ if not all_matches:
150
+ raise MigrationNotFoundError(
151
+ f"No migration found with version '{version}' in {migrations_dir}\n"
152
+ f"Hint: Migration files should be named like:\n"
153
+ f" - {version}_<name>.py (Python migration)\n"
154
+ f" - {version}_<name>.up.sql + {version}_<name>.down.sql (SQL-only)"
155
+ )
156
+
157
+ if len(all_matches) > 1:
158
+ file_names = [f.name for _, f in all_matches]
159
+ raise MigrationNotFoundError(
160
+ f"Multiple migrations found with version '{version}': {file_names}\n"
161
+ f"Hint: Use 'name' parameter to specify the exact migration"
162
+ )
163
+
164
+ migration_type, migration_file = all_matches[0]
165
+
166
+ if migration_type == "python":
167
+ return _load_python_migration(migration_file)
168
+ else:
169
+ # SQL migration
170
+ base_name = migration_file.name.replace(".up.sql", "")
171
+ down_file = migrations_dir / f"{base_name}.down.sql"
172
+ return FileSQLMigration.from_files(migration_file, down_file)
173
+
174
+
175
+ def _load_python_migration(migration_file: Path) -> type[Migration]:
176
+ """Load a Python migration from file."""
177
+ try:
178
+ module = load_migration_module(migration_file)
179
+ migration_class = get_migration_class(module)
180
+ return migration_class
181
+ except MigrationError:
182
+ raise
183
+ except Exception as e:
184
+ raise MigrationLoadError(
185
+ f"Failed to load migration from {migration_file}: {e}\n"
186
+ f"Hint: Check that the file contains a valid Migration subclass"
187
+ ) from e
188
+
189
+
190
+ def find_migration_by_version(
191
+ version: str,
192
+ migrations_dir: Path | None = None,
193
+ ) -> Path | None:
194
+ """Find a migration file by version prefix.
195
+
196
+ Searches for both Python migrations (.py) and SQL-only migrations
197
+ (.up.sql/.down.sql pairs).
198
+
199
+ Args:
200
+ version: Migration version prefix (e.g., "003")
201
+ migrations_dir: Custom migrations directory
202
+
203
+ Returns:
204
+ Path to the migration file (.py or .up.sql), or None if not found
205
+ or if multiple migrations have the same version.
206
+ """
207
+ if migrations_dir is None:
208
+ migrations_dir = Path("db/migrations")
209
+
210
+ if not migrations_dir.exists():
211
+ return None
212
+
213
+ # Find Python migrations
214
+ py_files = list(migrations_dir.glob(f"{version}_*.py"))
215
+
216
+ # Find SQL-only migrations (with matching .down.sql)
217
+ sql_files: list[Path] = []
218
+ for up_file in migrations_dir.glob(f"{version}_*.up.sql"):
219
+ base_name = up_file.name.replace(".up.sql", "")
220
+ down_file = migrations_dir / f"{base_name}.down.sql"
221
+ if down_file.exists():
222
+ sql_files.append(up_file)
223
+
224
+ all_files = py_files + sql_files
225
+ return all_files[0] if len(all_files) == 1 else None