fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.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.
- confiture/__init__.py +48 -0
- confiture/_core.cp311-win_amd64.pyd +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1656 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +132 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +793 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +0 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +180 -0
- confiture/models/schema.py +203 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +38 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
- fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Migration file checksum computation and verification.
|
|
2
|
+
|
|
3
|
+
Provides SHA-256 checksum computation and verification for migration files
|
|
4
|
+
to detect unauthorized modifications after migrations are applied.
|
|
5
|
+
|
|
6
|
+
This helps prevent:
|
|
7
|
+
- Silent schema drift between environments
|
|
8
|
+
- Production/staging mismatches
|
|
9
|
+
- Debugging nightmares from modified migrations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import psycopg
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChecksumMismatchBehavior(Enum):
|
|
26
|
+
"""Behavior when checksum mismatch is detected."""
|
|
27
|
+
|
|
28
|
+
FAIL = "fail" # Raise error, stop migrations
|
|
29
|
+
WARN = "warn" # Log warning, continue
|
|
30
|
+
IGNORE = "ignore" # Silently continue
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ChecksumConfig:
|
|
35
|
+
"""Configuration for checksum verification.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
enabled: Whether checksum verification is enabled (default: True)
|
|
39
|
+
on_mismatch: Behavior when mismatch detected (default: FAIL)
|
|
40
|
+
algorithm: Hash algorithm to use (default: sha256)
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
>>> config = ChecksumConfig(on_mismatch=ChecksumMismatchBehavior.WARN)
|
|
44
|
+
>>> config = ChecksumConfig(enabled=False) # Disable verification
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
on_mismatch: ChecksumMismatchBehavior = field(default=ChecksumMismatchBehavior.FAIL)
|
|
49
|
+
algorithm: str = "sha256"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ChecksumMismatch:
|
|
54
|
+
"""Record of a checksum mismatch.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
version: Migration version
|
|
58
|
+
name: Migration name
|
|
59
|
+
file_path: Path to migration file
|
|
60
|
+
expected: Expected checksum (stored in database)
|
|
61
|
+
actual: Actual checksum (computed from file)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
version: str
|
|
65
|
+
name: str
|
|
66
|
+
file_path: Path
|
|
67
|
+
expected: str
|
|
68
|
+
actual: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ChecksumVerificationError(Exception):
|
|
72
|
+
"""Raised when checksum verification fails.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
mismatches: List of ChecksumMismatch objects
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, mismatches: list[ChecksumMismatch]):
|
|
79
|
+
self.mismatches = mismatches
|
|
80
|
+
files = ", ".join(m.version for m in mismatches)
|
|
81
|
+
super().__init__(
|
|
82
|
+
f"Checksum verification failed for {len(mismatches)} migration(s): {files}. "
|
|
83
|
+
"Migration files have been modified after application. "
|
|
84
|
+
"This can cause schema drift between environments."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compute_checksum(file_path: Path, algorithm: str = "sha256") -> str:
|
|
89
|
+
"""Compute checksum of a migration file.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
file_path: Path to migration file
|
|
93
|
+
algorithm: Hash algorithm (default: sha256)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Hex-encoded checksum string (64 characters for SHA-256)
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> checksum = compute_checksum(Path("001_create_users.py"))
|
|
100
|
+
>>> print(checksum)
|
|
101
|
+
"a3f2b8c9d4e5f6a1b2c3d4e5f6a7b8c9..."
|
|
102
|
+
"""
|
|
103
|
+
hasher = hashlib.new(algorithm)
|
|
104
|
+
|
|
105
|
+
with open(file_path, "rb") as f:
|
|
106
|
+
# Read in chunks for memory efficiency with large files
|
|
107
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
108
|
+
hasher.update(chunk)
|
|
109
|
+
|
|
110
|
+
return hasher.hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compute_checksum_from_content(content: str | bytes, algorithm: str = "sha256") -> str:
|
|
114
|
+
"""Compute checksum from content directly.
|
|
115
|
+
|
|
116
|
+
Useful for computing checksums without writing to disk.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
content: File content as string or bytes
|
|
120
|
+
algorithm: Hash algorithm (default: sha256)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Hex-encoded checksum string
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> checksum = compute_checksum_from_content("def up(): pass")
|
|
127
|
+
>>> print(len(checksum))
|
|
128
|
+
64
|
|
129
|
+
"""
|
|
130
|
+
hasher = hashlib.new(algorithm)
|
|
131
|
+
|
|
132
|
+
if isinstance(content, str):
|
|
133
|
+
content = content.encode("utf-8")
|
|
134
|
+
|
|
135
|
+
hasher.update(content)
|
|
136
|
+
return hasher.hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class MigrationChecksumVerifier:
|
|
140
|
+
"""Verifies migration file integrity against stored checksums.
|
|
141
|
+
|
|
142
|
+
Compares the SHA-256 checksums of migration files on disk against
|
|
143
|
+
the checksums that were stored when the migrations were applied.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> import psycopg
|
|
147
|
+
>>> conn = psycopg.connect("postgresql://localhost/mydb")
|
|
148
|
+
>>> verifier = MigrationChecksumVerifier(conn)
|
|
149
|
+
>>> mismatches = verifier.verify_all(Path("db/migrations"))
|
|
150
|
+
>>> if mismatches:
|
|
151
|
+
... print(f"Found {len(mismatches)} modified migrations!")
|
|
152
|
+
|
|
153
|
+
>>> # With custom config
|
|
154
|
+
>>> config = ChecksumConfig(on_mismatch=ChecksumMismatchBehavior.WARN)
|
|
155
|
+
>>> verifier = MigrationChecksumVerifier(conn, config)
|
|
156
|
+
>>> verifier.verify_all(Path("db/migrations")) # Logs warnings instead of raising
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
connection: "psycopg.Connection",
|
|
162
|
+
config: ChecksumConfig | None = None,
|
|
163
|
+
):
|
|
164
|
+
"""Initialize verifier.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
connection: psycopg3 database connection
|
|
168
|
+
config: Checksum configuration (uses defaults if None)
|
|
169
|
+
"""
|
|
170
|
+
self.connection = connection
|
|
171
|
+
self.config = config or ChecksumConfig()
|
|
172
|
+
|
|
173
|
+
def verify_all(self, migrations_dir: Path) -> list[ChecksumMismatch]:
|
|
174
|
+
"""Verify all applied migrations against their stored checksums.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
migrations_dir: Directory containing migration files
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of mismatches (empty if all match)
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ChecksumVerificationError: If mismatches found and behavior is FAIL
|
|
184
|
+
"""
|
|
185
|
+
if not self.config.enabled:
|
|
186
|
+
logger.debug("Checksum verification disabled")
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
# Get stored checksums from database
|
|
190
|
+
stored = self._get_stored_checksums()
|
|
191
|
+
|
|
192
|
+
if not stored:
|
|
193
|
+
logger.debug("No stored checksums to verify")
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
mismatches = []
|
|
197
|
+
|
|
198
|
+
for version, (name, expected_checksum) in stored.items():
|
|
199
|
+
# Find migration file
|
|
200
|
+
file_path = self._find_migration_file(migrations_dir, version, name)
|
|
201
|
+
|
|
202
|
+
if file_path is None:
|
|
203
|
+
logger.warning(f"Migration file not found for {version}_{name}")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Compute current checksum
|
|
207
|
+
actual_checksum = compute_checksum(file_path, self.config.algorithm)
|
|
208
|
+
|
|
209
|
+
# Missing checksum is treated as mismatch
|
|
210
|
+
if expected_checksum is None or actual_checksum != expected_checksum:
|
|
211
|
+
mismatches.append(
|
|
212
|
+
ChecksumMismatch(
|
|
213
|
+
version=version,
|
|
214
|
+
name=name,
|
|
215
|
+
file_path=file_path,
|
|
216
|
+
expected=expected_checksum,
|
|
217
|
+
actual=actual_checksum,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Handle mismatches based on config
|
|
222
|
+
if mismatches:
|
|
223
|
+
self._handle_mismatches(mismatches)
|
|
224
|
+
|
|
225
|
+
return mismatches
|
|
226
|
+
|
|
227
|
+
def verify_single(self, migration_file: Path, expected_checksum: str) -> bool:
|
|
228
|
+
"""Verify a single migration file against expected checksum.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
migration_file: Path to migration file
|
|
232
|
+
expected_checksum: Expected checksum value
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if checksums match, False otherwise
|
|
236
|
+
"""
|
|
237
|
+
if not self.config.enabled:
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
actual = compute_checksum(migration_file, self.config.algorithm)
|
|
241
|
+
return actual == expected_checksum
|
|
242
|
+
|
|
243
|
+
def _get_stored_checksums(self) -> dict[str, tuple[str, str | None]]:
|
|
244
|
+
"""Get stored checksums from database.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Dict mapping version -> (name, checksum)
|
|
248
|
+
"""
|
|
249
|
+
with self.connection.cursor() as cur:
|
|
250
|
+
cur.execute("""
|
|
251
|
+
SELECT version, name, checksum
|
|
252
|
+
FROM confiture_migrations
|
|
253
|
+
ORDER BY version
|
|
254
|
+
""")
|
|
255
|
+
return {row[0]: (row[1], row[2]) for row in cur.fetchall()}
|
|
256
|
+
|
|
257
|
+
def _find_migration_file(
|
|
258
|
+
self,
|
|
259
|
+
migrations_dir: Path,
|
|
260
|
+
version: str,
|
|
261
|
+
name: str,
|
|
262
|
+
) -> Path | None:
|
|
263
|
+
"""Find migration file by version and name.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
migrations_dir: Directory to search
|
|
267
|
+
version: Migration version
|
|
268
|
+
name: Migration name
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Path to migration file, or None if not found
|
|
272
|
+
"""
|
|
273
|
+
# Try exact match first
|
|
274
|
+
exact_path = migrations_dir / f"{version}_{name}.py"
|
|
275
|
+
if exact_path.exists():
|
|
276
|
+
return exact_path
|
|
277
|
+
|
|
278
|
+
# Try pattern match (in case name has slight differences)
|
|
279
|
+
for f in migrations_dir.glob(f"{version}_*.py"):
|
|
280
|
+
return f
|
|
281
|
+
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def _handle_mismatches(self, mismatches: list[ChecksumMismatch]) -> None:
|
|
285
|
+
"""Handle checksum mismatches based on configuration.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
mismatches: List of detected mismatches
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ChecksumVerificationError: If behavior is FAIL
|
|
292
|
+
"""
|
|
293
|
+
behavior = self.config.on_mismatch
|
|
294
|
+
|
|
295
|
+
if behavior == ChecksumMismatchBehavior.IGNORE:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# Build message
|
|
299
|
+
msg_lines = ["Checksum verification found modified migrations:"]
|
|
300
|
+
for m in mismatches:
|
|
301
|
+
msg_lines.append(
|
|
302
|
+
f" - {m.version}_{m.name}: expected {m.expected[:12]}..., got {m.actual[:12]}..."
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
message = "\n".join(msg_lines)
|
|
306
|
+
|
|
307
|
+
if behavior == ChecksumMismatchBehavior.WARN:
|
|
308
|
+
logger.warning(message)
|
|
309
|
+
elif behavior == ChecksumMismatchBehavior.FAIL:
|
|
310
|
+
raise ChecksumVerificationError(mismatches)
|
|
311
|
+
|
|
312
|
+
def update_checksum(self, version: str, new_checksum: str) -> None:
|
|
313
|
+
"""Update stored checksum for a migration.
|
|
314
|
+
|
|
315
|
+
Use with caution - this should only be used when you're certain
|
|
316
|
+
the file modification was intentional.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
version: Migration version
|
|
320
|
+
new_checksum: New checksum value
|
|
321
|
+
"""
|
|
322
|
+
with self.connection.cursor() as cur:
|
|
323
|
+
cur.execute(
|
|
324
|
+
"""
|
|
325
|
+
UPDATE confiture_migrations
|
|
326
|
+
SET checksum = %s
|
|
327
|
+
WHERE version = %s
|
|
328
|
+
""",
|
|
329
|
+
(new_checksum, version),
|
|
330
|
+
)
|
|
331
|
+
self.connection.commit()
|
|
332
|
+
logger.info(f"Updated checksum for migration {version}")
|
|
333
|
+
|
|
334
|
+
def update_all_checksums(self, migrations_dir: Path) -> int:
|
|
335
|
+
"""Update all stored checksums from current files.
|
|
336
|
+
|
|
337
|
+
WARNING: This should only be used when you're certain all
|
|
338
|
+
file modifications were intentional.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
migrations_dir: Directory containing migration files
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
Number of checksums updated
|
|
345
|
+
"""
|
|
346
|
+
stored = self._get_stored_checksums()
|
|
347
|
+
updated = 0
|
|
348
|
+
|
|
349
|
+
for version, (name, _) in stored.items():
|
|
350
|
+
file_path = self._find_migration_file(migrations_dir, version, name)
|
|
351
|
+
if file_path is None:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
new_checksum = compute_checksum(file_path, self.config.algorithm)
|
|
355
|
+
self.update_checksum(version, new_checksum)
|
|
356
|
+
updated += 1
|
|
357
|
+
|
|
358
|
+
return updated
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Database connection management for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import psycopg
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from confiture.exceptions import MigrationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config(config_file: Path) -> dict[str, Any]:
|
|
16
|
+
"""Load configuration from YAML file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config_file: Path to configuration file
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Configuration dictionary
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
MigrationError: If config file is invalid
|
|
26
|
+
"""
|
|
27
|
+
if not config_file.exists():
|
|
28
|
+
raise MigrationError(f"Configuration file not found: {config_file}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
with open(config_file) as f:
|
|
32
|
+
config: dict[str, Any] = yaml.safe_load(f)
|
|
33
|
+
return config
|
|
34
|
+
except yaml.YAMLError as e:
|
|
35
|
+
raise MigrationError(f"Invalid YAML configuration: {e}") from e
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_connection(config: dict[str, Any] | Any) -> psycopg.Connection:
|
|
39
|
+
"""Create database connection from configuration.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: Configuration dictionary with 'database' section, 'database_url', or DatabaseConfig instance
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
PostgreSQL connection
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
MigrationError: If connection fails
|
|
49
|
+
"""
|
|
50
|
+
from confiture.config.environment import DatabaseConfig
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Handle DatabaseConfig instance
|
|
54
|
+
if isinstance(config, DatabaseConfig):
|
|
55
|
+
config_dict = config.to_dict()
|
|
56
|
+
db_config = config_dict.get("database", {})
|
|
57
|
+
conn = psycopg.connect(
|
|
58
|
+
host=db_config.get("host", "localhost"),
|
|
59
|
+
port=db_config.get("port", 5432),
|
|
60
|
+
dbname=db_config.get("database", "postgres"),
|
|
61
|
+
user=db_config.get("user", "postgres"),
|
|
62
|
+
password=db_config.get("password", ""),
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
# Check for database_url first
|
|
66
|
+
database_url = config.get("database_url")
|
|
67
|
+
if database_url:
|
|
68
|
+
conn = psycopg.connect(database_url)
|
|
69
|
+
else:
|
|
70
|
+
# Fall back to database section
|
|
71
|
+
db_config = config.get("database", {})
|
|
72
|
+
conn = psycopg.connect(
|
|
73
|
+
host=db_config.get("host", "localhost"),
|
|
74
|
+
port=db_config.get("port", 5432),
|
|
75
|
+
dbname=db_config.get("database", "postgres"),
|
|
76
|
+
user=db_config.get("user", "postgres"),
|
|
77
|
+
password=db_config.get("password", ""),
|
|
78
|
+
)
|
|
79
|
+
return conn
|
|
80
|
+
except psycopg.Error as e:
|
|
81
|
+
raise MigrationError(f"Failed to connect to database: {e}") from e
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_migration_module(migration_file: Path) -> ModuleType:
|
|
85
|
+
"""Dynamically load a migration Python module.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
migration_file: Path to migration .py file
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Loaded module
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
MigrationError: If module cannot be loaded
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
# Create module spec
|
|
98
|
+
spec = importlib.util.spec_from_file_location(migration_file.stem, migration_file)
|
|
99
|
+
if spec is None or spec.loader is None:
|
|
100
|
+
raise MigrationError(f"Cannot load migration: {migration_file}")
|
|
101
|
+
|
|
102
|
+
# Load module
|
|
103
|
+
module = importlib.util.module_from_spec(spec)
|
|
104
|
+
sys.modules[migration_file.stem] = module
|
|
105
|
+
spec.loader.exec_module(module)
|
|
106
|
+
|
|
107
|
+
return module
|
|
108
|
+
except Exception as e:
|
|
109
|
+
raise MigrationError(f"Failed to load migration {migration_file}: {e}") from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_migration_class(module: ModuleType) -> type:
|
|
113
|
+
"""Extract Migration subclass from loaded module.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
module: Loaded Python module
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Migration class
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
MigrationError: If no Migration class found
|
|
123
|
+
"""
|
|
124
|
+
from confiture.models.migration import Migration
|
|
125
|
+
|
|
126
|
+
# Find Migration subclass in module
|
|
127
|
+
for attr_name in dir(module):
|
|
128
|
+
attr = getattr(module, attr_name)
|
|
129
|
+
if isinstance(attr, type) and issubclass(attr, Migration) and attr is not Migration:
|
|
130
|
+
return attr
|
|
131
|
+
|
|
132
|
+
raise MigrationError(f"No Migration subclass found in {module}")
|