envdrift 4.2.1__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.
Files changed (52) hide show
  1. envdrift/__init__.py +30 -0
  2. envdrift/_version.py +34 -0
  3. envdrift/api.py +192 -0
  4. envdrift/cli.py +42 -0
  5. envdrift/cli_commands/__init__.py +1 -0
  6. envdrift/cli_commands/diff.py +91 -0
  7. envdrift/cli_commands/encryption.py +630 -0
  8. envdrift/cli_commands/encryption_helpers.py +93 -0
  9. envdrift/cli_commands/hook.py +75 -0
  10. envdrift/cli_commands/init_cmd.py +117 -0
  11. envdrift/cli_commands/partial.py +222 -0
  12. envdrift/cli_commands/sync.py +1140 -0
  13. envdrift/cli_commands/validate.py +109 -0
  14. envdrift/cli_commands/vault.py +376 -0
  15. envdrift/cli_commands/version.py +15 -0
  16. envdrift/config.py +489 -0
  17. envdrift/constants.json +18 -0
  18. envdrift/core/__init__.py +30 -0
  19. envdrift/core/diff.py +233 -0
  20. envdrift/core/encryption.py +400 -0
  21. envdrift/core/parser.py +260 -0
  22. envdrift/core/partial_encryption.py +239 -0
  23. envdrift/core/schema.py +253 -0
  24. envdrift/core/validator.py +312 -0
  25. envdrift/encryption/__init__.py +117 -0
  26. envdrift/encryption/base.py +217 -0
  27. envdrift/encryption/dotenvx.py +236 -0
  28. envdrift/encryption/sops.py +458 -0
  29. envdrift/env_files.py +60 -0
  30. envdrift/integrations/__init__.py +21 -0
  31. envdrift/integrations/dotenvx.py +689 -0
  32. envdrift/integrations/precommit.py +266 -0
  33. envdrift/integrations/sops.py +85 -0
  34. envdrift/output/__init__.py +21 -0
  35. envdrift/output/rich.py +424 -0
  36. envdrift/py.typed +0 -0
  37. envdrift/sync/__init__.py +26 -0
  38. envdrift/sync/config.py +218 -0
  39. envdrift/sync/engine.py +383 -0
  40. envdrift/sync/operations.py +138 -0
  41. envdrift/sync/result.py +99 -0
  42. envdrift/vault/__init__.py +107 -0
  43. envdrift/vault/aws.py +282 -0
  44. envdrift/vault/azure.py +170 -0
  45. envdrift/vault/base.py +150 -0
  46. envdrift/vault/gcp.py +210 -0
  47. envdrift/vault/hashicorp.py +238 -0
  48. envdrift-4.2.1.dist-info/METADATA +160 -0
  49. envdrift-4.2.1.dist-info/RECORD +52 -0
  50. envdrift-4.2.1.dist-info/WHEEL +4 -0
  51. envdrift-4.2.1.dist-info/entry_points.txt +2 -0
  52. envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,424 @@
1
+ """Rich console formatting for envdrift output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from envdrift.core.diff import DiffResult, DiffType
14
+ from envdrift.core.encryption import EncryptionReport
15
+ from envdrift.core.schema import SchemaMetadata
16
+ from envdrift.core.validator import ValidationResult
17
+
18
+ if TYPE_CHECKING:
19
+ from envdrift.sync.result import ServiceSyncResult, SyncResult
20
+
21
+ console = Console()
22
+
23
+
24
+ def print_success(message: str) -> None:
25
+ """Print a success message."""
26
+ console.print(f"[green][OK][/green] {message}")
27
+
28
+
29
+ def print_error(message: str) -> None:
30
+ """
31
+ Print a message prefixed with a red "ERROR" badge to the module console.
32
+ """
33
+ console.print(f"[red][ERROR][/red] {message}")
34
+
35
+
36
+ def print_warning(message: str) -> None:
37
+ """
38
+ Display a yellow "WARN" badge followed by the provided message to the shared console.
39
+ """
40
+ console.print(f"[yellow][WARN][/yellow] {message}")
41
+
42
+
43
+ def print_validation_result(
44
+ result: ValidationResult,
45
+ env_path: Path,
46
+ schema: SchemaMetadata,
47
+ verbose: bool = False,
48
+ ) -> None:
49
+ """
50
+ Render a formatted validation report for an environment file against a schema using Rich console output.
51
+
52
+ Prints a header with the environment path and schema, then a PASS or FAIL status. When validation fails, prints any of the following sections as applicable: missing required variables (with schema descriptions when available), extra variables, unencrypted secrets, type errors, warnings, and — when `verbose` is true — missing optional variables (with defaults when available). Finally prints a summary of error and warning counts and a short hint to run with --fix.
53
+
54
+ Parameters:
55
+ result (ValidationResult): Validation outcome containing flags and lists of issues.
56
+ env_path (Path): Filesystem path to the validated environment file.
57
+ schema (SchemaMetadata): Schema metadata used for validation (fields, descriptions, defaults).
58
+ verbose (bool): If true, include missing optional variables and their default information.
59
+ """
60
+ # Header
61
+ console.print()
62
+ console.print(
63
+ Panel(
64
+ f"[bold]Validating:[/bold] {env_path}\n"
65
+ f"[bold]Schema:[/bold] {schema.module_path}:{schema.class_name}",
66
+ title="envdrift validate",
67
+ )
68
+ )
69
+
70
+ if result.valid:
71
+ console.print()
72
+ console.print("[bold green]Validation PASSED[/bold green]")
73
+ else:
74
+ console.print()
75
+ console.print("[bold red]Validation FAILED[/bold red]")
76
+ console.print()
77
+
78
+ # Missing required variables
79
+ if result.missing_required:
80
+ console.print("[bold red]MISSING REQUIRED VARIABLES:[/bold red]")
81
+ for var in sorted(result.missing_required):
82
+ field_meta = schema.fields.get(var)
83
+ desc = f" - {field_meta.description}" if field_meta and field_meta.description else ""
84
+ console.print(f" [red]*[/red] {var}{desc}")
85
+ console.print()
86
+
87
+ # Extra variables
88
+ if result.extra_vars:
89
+ console.print("[bold yellow]EXTRA VARIABLES (not in schema):[/bold yellow]")
90
+ for var in sorted(result.extra_vars):
91
+ console.print(f" [yellow]*[/yellow] {var}")
92
+ console.print()
93
+
94
+ # Unencrypted secrets (warning, not error)
95
+ if result.unencrypted_secrets:
96
+ console.print("[bold yellow]UNENCRYPTED SECRETS (warning):[/bold yellow]")
97
+ for var in sorted(result.unencrypted_secrets):
98
+ console.print(f" [yellow]*[/yellow] {var} (marked sensitive but not encrypted)")
99
+ console.print("[dim] Run 'envdrift encrypt --check' for strict enforcement[/dim]")
100
+ console.print()
101
+
102
+ # Type errors
103
+ if result.type_errors:
104
+ console.print("[bold red]TYPE ERRORS:[/bold red]")
105
+ for var, error in sorted(result.type_errors.items()):
106
+ console.print(f" [red]*[/red] {var}: {error}")
107
+ console.print()
108
+
109
+ # Warnings
110
+ if result.warnings:
111
+ console.print("[bold yellow]WARNINGS:[/bold yellow]")
112
+ for warning in result.warnings:
113
+ console.print(f" [yellow]*[/yellow] {warning}")
114
+ console.print()
115
+
116
+ # Missing optional (verbose only)
117
+ if verbose and result.missing_optional:
118
+ console.print("[dim]MISSING OPTIONAL VARIABLES (have defaults):[/dim]")
119
+ for var in sorted(result.missing_optional):
120
+ field_meta = schema.fields.get(var)
121
+ has_default = field_meta and field_meta.default is not None
122
+ default = f" (default: {field_meta.default})" if has_default else ""
123
+ console.print(f" [dim]*[/dim] {var}{default}")
124
+ console.print()
125
+
126
+ # Summary (show if there are any issues)
127
+ err_count = result.error_count
128
+ warn_count = result.warning_count
129
+ if err_count > 0 or warn_count > 0:
130
+ console.print(f"[bold]Summary:[/bold] {err_count} error(s), {warn_count} warning(s)")
131
+ console.print()
132
+ if err_count > 0:
133
+ console.print("[dim]Run with --fix to generate template for missing variables.[/dim]")
134
+
135
+
136
+ def print_diff_result(result: DiffResult, show_unchanged: bool = False) -> None:
137
+ """
138
+ Render a human-readable comparison of two environments to the shared console.
139
+
140
+ Prints a header showing the two environment paths, a table of variable differences (optionally including unchanged entries), and a concise summary of added/removed/changed counts with a drift notice when differences exist.
141
+
142
+ Parameters:
143
+ result (DiffResult): The computed diff between two environments, including paths, per-variable differences, and aggregate counts.
144
+ show_unchanged (bool): If True, include variables that are identical in both environments in the output; otherwise omit them.
145
+ """
146
+ console.print()
147
+ console.print(
148
+ Panel(
149
+ f"[bold]Comparing:[/bold] {result.env1_path} vs {result.env2_path}",
150
+ title="envdrift diff",
151
+ )
152
+ )
153
+
154
+ if not result.has_drift:
155
+ console.print()
156
+ console.print("[bold green]No drift detected - environments match[/bold green]")
157
+ console.print()
158
+ return
159
+
160
+ # Create table
161
+ table = Table(show_header=True, header_style="bold")
162
+ table.add_column("Variable", style="cyan")
163
+ table.add_column(str(result.env1_path.name), style="dim")
164
+ table.add_column(str(result.env2_path.name), style="dim")
165
+ table.add_column("Status", justify="center")
166
+
167
+ for diff in result.differences:
168
+ if diff.diff_type == DiffType.UNCHANGED and not show_unchanged:
169
+ continue
170
+
171
+ # Format status
172
+ if diff.diff_type == DiffType.ADDED:
173
+ status = Text("added", style="green")
174
+ value1 = Text("(missing)", style="dim")
175
+ value2 = Text(str(diff.value2) if diff.value2 else "", style="green")
176
+ elif diff.diff_type == DiffType.REMOVED:
177
+ status = Text("removed", style="red")
178
+ value1 = Text(str(diff.value1) if diff.value1 else "", style="red")
179
+ value2 = Text("(missing)", style="dim")
180
+ elif diff.diff_type == DiffType.CHANGED:
181
+ status = Text("changed", style="yellow")
182
+ value1 = Text(str(diff.value1) if diff.value1 else "", style="yellow")
183
+ value2 = Text(str(diff.value2) if diff.value2 else "", style="yellow")
184
+ else:
185
+ status = Text("unchanged", style="dim")
186
+ value1 = Text(str(diff.value1) if diff.value1 else "", style="dim")
187
+ value2 = Text(str(diff.value2) if diff.value2 else "", style="dim")
188
+
189
+ # Mark sensitive values
190
+ if diff.is_sensitive:
191
+ name_text = Text()
192
+ name_text.append(diff.name)
193
+ name_text.append(" (sensitive)", style="dim")
194
+ else:
195
+ name_text = Text(diff.name)
196
+
197
+ table.add_row(name_text, value1, value2, status)
198
+
199
+ console.print()
200
+ console.print(table)
201
+ console.print()
202
+
203
+ # Summary
204
+ summary_parts = []
205
+ if result.changed_count:
206
+ summary_parts.append(f"[yellow]{result.changed_count} changed[/yellow]")
207
+ if result.added_count:
208
+ summary_parts.append(f"[green]{result.added_count} added[/green]")
209
+ if result.removed_count:
210
+ summary_parts.append(f"[red]{result.removed_count} removed[/red]")
211
+
212
+ console.print(f"[bold]Summary:[/bold] {', '.join(summary_parts)}")
213
+
214
+ if result.has_drift:
215
+ console.print()
216
+ console.print("[yellow]Drift detected between environments[/yellow]")
217
+
218
+
219
+ def print_encryption_report(report: EncryptionReport) -> None:
220
+ """
221
+ Render a human-readable encryption summary for a file, including overall status, variable counts, detected plaintext secrets, warnings, and a remediation suggestion.
222
+
223
+ Parameters:
224
+ report (EncryptionReport): EncryptionReport for the inspected file (provides path, encryption ratios, lists of encrypted/plaintext/empty variables, plaintext secrets, and warnings).
225
+ """
226
+ console.print()
227
+ console.print(
228
+ Panel(f"[bold]Encryption Status:[/bold] {report.path}", title="envdrift encrypt --check")
229
+ )
230
+
231
+ # Overall status
232
+ if report.is_fully_encrypted:
233
+ console.print()
234
+ console.print("[bold green]File is fully encrypted[/bold green]")
235
+ elif report.encrypted_vars and report.plaintext_vars:
236
+ console.print()
237
+ console.print("[bold yellow]File is partially encrypted[/bold yellow]")
238
+ else:
239
+ console.print()
240
+ console.print("[bold red]File is not encrypted[/bold red]")
241
+
242
+ console.print()
243
+
244
+ # Statistics
245
+ console.print("[bold]Variables:[/bold]")
246
+ console.print(f" Encrypted: {len(report.encrypted_vars)}")
247
+ console.print(f" Plaintext: {len(report.plaintext_vars)}")
248
+ console.print(f" Empty: {len(report.empty_vars)}")
249
+ console.print(f" Encryption ratio: {report.encryption_ratio:.0%}")
250
+ console.print()
251
+
252
+ # Plaintext secrets (critical)
253
+ if report.plaintext_secrets:
254
+ console.print("[bold red]PLAINTEXT SECRETS DETECTED:[/bold red]")
255
+ for var in sorted(report.plaintext_secrets):
256
+ console.print(f" [red]*[/red] {var}")
257
+ console.print()
258
+
259
+ # Warnings
260
+ if report.warnings:
261
+ console.print("[bold yellow]WARNINGS:[/bold yellow]")
262
+ for warning in report.warnings:
263
+ console.print(f" [yellow]*[/yellow] {warning}")
264
+ console.print()
265
+
266
+ # Recommendation
267
+ if report.plaintext_secrets:
268
+ console.print("[bold]Recommendation:[/bold]")
269
+ if report.detected_backend == "sops":
270
+ console.print(
271
+ f" Run: [cyan]envdrift encrypt --backend sops[/cyan] [dim]{report.path}[/dim]"
272
+ )
273
+ else:
274
+ console.print(f" Run: [cyan]envdrift encrypt[/cyan] [dim]{report.path}[/dim]")
275
+ console.print()
276
+
277
+
278
+ def print_sync_summary(
279
+ services_processed: int,
280
+ created: int,
281
+ updated: int,
282
+ skipped: int,
283
+ errors: int,
284
+ ) -> None:
285
+ """
286
+ Prints a summary of vault synchronization results.
287
+
288
+ Parameters:
289
+ services_processed (int): Total number of services that were processed.
290
+ created (int): Number of new keys created.
291
+ updated (int): Number of keys updated.
292
+ skipped (int): Number of keys skipped because no change was needed.
293
+ errors (int): Number of services that failed during syncing.
294
+ """
295
+ console.print()
296
+ console.print(
297
+ Panel(
298
+ f"[bold]Services processed:[/bold] {services_processed}\n"
299
+ f"[green]Created:[/green] {created}\n"
300
+ f"[yellow]Updated:[/yellow] {updated}\n"
301
+ f"[dim]Skipped:[/dim] {skipped}\n"
302
+ f"[red]Errors:[/red] {errors}",
303
+ title="Sync Summary",
304
+ )
305
+ )
306
+
307
+ if errors == 0:
308
+ console.print("[bold green]All services synced successfully[/bold green]")
309
+ else:
310
+ console.print(f"[bold red]{errors} service(s) failed[/bold red]")
311
+
312
+
313
+ def print_service_sync_status(result: ServiceSyncResult) -> None:
314
+ """
315
+ Print status for a single service sync operation.
316
+
317
+ Parameters:
318
+ result (ServiceSyncResult): Result of syncing a single service.
319
+ """
320
+ from envdrift.sync.result import DecryptionTestResult, SyncAction
321
+
322
+ # Determine status icon and color
323
+ if result.action == SyncAction.CREATED:
324
+ icon = "[green]+[/green]"
325
+ status = "[green]created[/green]"
326
+ elif result.action == SyncAction.UPDATED:
327
+ icon = "[yellow]~[/yellow]"
328
+ status = "[yellow]updated[/yellow]"
329
+ elif result.action == SyncAction.SKIPPED:
330
+ icon = "[dim]=[/dim]"
331
+ status = "[dim]skipped[/dim]"
332
+ else: # ERROR
333
+ icon = "[red]x[/red]"
334
+ status = "[red]error[/red]"
335
+
336
+ console.print(f" {icon} {result.folder_path} - {status}")
337
+
338
+ # Show error details
339
+ if result.error:
340
+ console.print(f" [red]Error: {result.error}[/red]")
341
+
342
+ # Show mismatch preview
343
+ if result.local_value_preview and result.vault_value_preview:
344
+ if result.local_value_preview != result.vault_value_preview:
345
+ console.print(f" [dim]Local: {result.local_value_preview}[/dim]")
346
+ console.print(f" [dim]Vault: {result.vault_value_preview}[/dim]")
347
+
348
+ # Show backup path
349
+ if result.backup_path:
350
+ console.print(f" [dim]Backup: {result.backup_path}[/dim]")
351
+
352
+ # Show decryption test result
353
+ if result.decryption_result is not None:
354
+ if result.decryption_result == DecryptionTestResult.PASSED:
355
+ console.print(" [green]Decryption: PASSED[/green]")
356
+ elif result.decryption_result == DecryptionTestResult.FAILED:
357
+ console.print(" [red]Decryption: FAILED[/red]")
358
+ else:
359
+ console.print(" [dim]Decryption: skipped (no encrypted file)[/dim]")
360
+
361
+ # Show schema validation result
362
+ if result.schema_valid is not None:
363
+ if result.schema_valid:
364
+ console.print(" [green]Schema: valid[/green]")
365
+ else:
366
+ console.print(" [red]Schema: invalid[/red]")
367
+
368
+
369
+ def print_sync_result(result: SyncResult) -> None:
370
+ """
371
+ Print complete sync results with summary.
372
+
373
+ Parameters:
374
+ result (SyncResult): Aggregate sync results.
375
+ """
376
+
377
+ console.print()
378
+
379
+ # Build summary panel content
380
+ lines = [
381
+ f"[bold]Services processed:[/bold] {result.total_processed}",
382
+ f"[green]Created:[/green] {result.created_count}",
383
+ f"[yellow]Updated:[/yellow] {result.updated_count}",
384
+ f"[dim]Skipped:[/dim] {result.skipped_count}",
385
+ f"[red]Errors:[/red] {result.error_count}",
386
+ ]
387
+
388
+ # Add decryption stats if any were tested
389
+ if result.decryption_tested > 0:
390
+ lines.append("")
391
+ lines.append("[bold]Decryption Tests:[/bold]")
392
+ lines.append(f" [green]Passed:[/green] {result.decryption_passed}")
393
+ lines.append(f" [red]Failed:[/red] {result.decryption_failed}")
394
+ skipped = result.decryption_tested - result.decryption_passed - result.decryption_failed
395
+ if skipped > 0:
396
+ lines.append(f" [dim]Skipped:[/dim] {skipped}")
397
+
398
+ console.print(Panel("\n".join(lines), title="Sync Summary"))
399
+
400
+ # Final status message
401
+ if result.has_errors:
402
+ console.print("[bold red]Sync completed with errors[/bold red]")
403
+ else:
404
+ console.print("[bold green]All services synced successfully[/bold green]")
405
+
406
+
407
+ def print_mismatch_warning(
408
+ service_name: str,
409
+ local_preview: str,
410
+ vault_preview: str,
411
+ ) -> None:
412
+ """
413
+ Print value mismatch warning.
414
+
415
+ Parameters:
416
+ service_name (str): Name of the service with mismatched values.
417
+ local_preview (str): Preview of local value (first N chars).
418
+ vault_preview (str): Preview of vault value (first N chars).
419
+ """
420
+ console.print()
421
+ console.print(f"[bold yellow]VALUE MISMATCH: {service_name}[/bold yellow]")
422
+ console.print(f" Local: {local_preview}")
423
+ console.print(f" Vault: {vault_preview}")
424
+ console.print()
envdrift/py.typed ADDED
File without changes
@@ -0,0 +1,26 @@
1
+ """Vault sync module for synchronizing encryption keys from cloud vaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from envdrift.sync.config import ServiceMapping, SyncConfig
6
+ from envdrift.sync.engine import SyncEngine, SyncMode
7
+ from envdrift.sync.operations import EnvKeysFile, atomic_write
8
+ from envdrift.sync.result import (
9
+ DecryptionTestResult,
10
+ ServiceSyncResult,
11
+ SyncAction,
12
+ SyncResult,
13
+ )
14
+
15
+ __all__ = [
16
+ "DecryptionTestResult",
17
+ "EnvKeysFile",
18
+ "ServiceMapping",
19
+ "ServiceSyncResult",
20
+ "SyncAction",
21
+ "SyncConfig",
22
+ "SyncEngine",
23
+ "SyncMode",
24
+ "SyncResult",
25
+ "atomic_write",
26
+ ]
@@ -0,0 +1,218 @@
1
+ """Sync configuration models and parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class SyncConfigError(Exception):
12
+ """Error loading sync configuration."""
13
+
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class ServiceMapping:
19
+ """Mapping of a vault secret to a local service folder."""
20
+
21
+ secret_name: str
22
+ folder_path: Path
23
+ vault_name: str | None = None
24
+ environment: str | None = None # Defaults to profile if set, else "production"
25
+ profile: str | None = None # Profile name for filtering (e.g., "local", "prod")
26
+ activate_to: Path | None = None # Path to copy decrypted file when profile is activated
27
+
28
+ @property
29
+ def effective_environment(self) -> str:
30
+ """
31
+ Return the effective environment.
32
+
33
+ Priority: explicit environment > profile > "production"
34
+ """
35
+ if self.environment is not None:
36
+ return self.environment
37
+ if self.profile is not None:
38
+ return self.profile
39
+ return "production"
40
+
41
+ @property
42
+ def env_key_name(self) -> str:
43
+ """Return the environment key name (e.g., DOTENV_PRIVATE_KEY_PRODUCTION)."""
44
+ return f"DOTENV_PRIVATE_KEY_{self.effective_environment.upper()}"
45
+
46
+
47
+ @dataclass
48
+ class SyncConfig:
49
+ """Complete sync configuration."""
50
+
51
+ mappings: list[ServiceMapping] = field(default_factory=list)
52
+ default_vault_name: str | None = None
53
+ env_keys_filename: str = ".env.keys"
54
+
55
+ @classmethod
56
+ def from_file(cls, path: Path) -> SyncConfig:
57
+ """
58
+ Load sync config from a pair.txt-style file.
59
+
60
+ Format:
61
+ # Comments start with #
62
+ secret-name=folder-path
63
+ vault-name/secret-name=folder-path
64
+ """
65
+ if not path.exists():
66
+ raise SyncConfigError(f"Config file not found: {path}")
67
+
68
+ mappings: list[ServiceMapping] = []
69
+
70
+ with path.open() as f:
71
+ for line_num, line in enumerate(f, 1):
72
+ line = line.strip()
73
+
74
+ # Skip empty lines and comments
75
+ if not line or line.startswith("#"):
76
+ continue
77
+
78
+ # Parse key=value
79
+ if "=" not in line:
80
+ raise SyncConfigError(
81
+ f"Invalid format at line {line_num}: {line!r}. "
82
+ "Expected: secret-name=folder-path"
83
+ )
84
+
85
+ secret_part, folder_path = line.split("=", 1)
86
+ secret_part = secret_part.strip()
87
+ folder_path = folder_path.strip()
88
+
89
+ if not secret_part or not folder_path:
90
+ raise SyncConfigError(f"Empty value at line {line_num}: {line!r}")
91
+
92
+ # Check for vault-name/secret-name format
93
+ if "/" in secret_part:
94
+ vault_name, secret_name = secret_part.split("/", 1)
95
+ vault_name = vault_name.strip()
96
+ secret_name = secret_name.strip()
97
+ else:
98
+ vault_name = None
99
+ secret_name = secret_part
100
+
101
+ mappings.append(
102
+ ServiceMapping(
103
+ secret_name=secret_name,
104
+ folder_path=Path(folder_path),
105
+ vault_name=vault_name,
106
+ )
107
+ )
108
+
109
+ return cls(mappings=mappings)
110
+
111
+ @classmethod
112
+ def from_toml(cls, data: dict[str, Any]) -> SyncConfig:
113
+ """
114
+ Load sync config from TOML [vault.sync] section.
115
+
116
+ Format:
117
+ [vault.sync]
118
+ default_vault_name = "my-keyvault"
119
+ env_keys_filename = ".env.keys"
120
+
121
+ [[vault.sync.mappings]]
122
+ secret_name = "myapp-key"
123
+ folder_path = "services/myapp"
124
+ vault_name = "other-vault" # Optional
125
+ environment = "staging" # Optional
126
+ """
127
+ mappings: list[ServiceMapping] = []
128
+
129
+ for mapping_data in data.get("mappings", []):
130
+ if "secret_name" not in mapping_data:
131
+ raise SyncConfigError("Missing 'secret_name' in mapping")
132
+ if "folder_path" not in mapping_data:
133
+ raise SyncConfigError("Missing 'folder_path' in mapping")
134
+
135
+ activate_to = mapping_data.get("activate_to")
136
+ mappings.append(
137
+ ServiceMapping(
138
+ secret_name=mapping_data["secret_name"],
139
+ folder_path=Path(mapping_data["folder_path"]),
140
+ vault_name=mapping_data.get("vault_name"),
141
+ environment=mapping_data.get("environment"), # None = use effective_environment
142
+ profile=mapping_data.get("profile"),
143
+ activate_to=Path(activate_to) if activate_to else None,
144
+ )
145
+ )
146
+
147
+ return cls(
148
+ mappings=mappings,
149
+ default_vault_name=data.get("default_vault_name"),
150
+ env_keys_filename=data.get("env_keys_filename", ".env.keys"),
151
+ )
152
+
153
+ def get_effective_vault_name(self, mapping: ServiceMapping) -> str | None:
154
+ """Get the effective vault name for a mapping (mapping override or default)."""
155
+ return mapping.vault_name or self.default_vault_name
156
+
157
+ def filter_by_profile(self, profile: str | None) -> list[ServiceMapping]:
158
+ """
159
+ Filter mappings by profile.
160
+
161
+ If profile is None, returns only mappings without a profile (regular mappings).
162
+ If profile is specified, returns:
163
+ - All mappings without a profile (regular mappings)
164
+ - Plus the mapping that matches the specified profile
165
+ """
166
+ if profile is None:
167
+ # No profile specified: return only non-profile mappings
168
+ return [m for m in self.mappings if m.profile is None]
169
+
170
+ # Profile specified: return non-profile mappings + matching profile
171
+ return [m for m in self.mappings if m.profile is None or m.profile == profile]
172
+
173
+ @classmethod
174
+ def from_toml_file(cls, path: Path) -> SyncConfig:
175
+ """
176
+ Load sync config from a TOML file.
177
+
178
+ Supports both standalone TOML files with [vault.sync] section
179
+ and pyproject.toml with [tool.envdrift.vault.sync] section.
180
+
181
+ Format:
182
+ [vault.sync]
183
+ default_vault_name = "my-keyvault"
184
+ env_keys_filename = ".env.keys"
185
+
186
+ [[vault.sync.mappings]]
187
+ secret_name = "myapp-key"
188
+ folder_path = "services/myapp"
189
+ vault_name = "other-vault" # Optional
190
+ environment = "staging" # Optional
191
+ """
192
+ if not path.exists():
193
+ raise SyncConfigError(f"Config file not found: {path}")
194
+
195
+ try:
196
+ with path.open("rb") as f:
197
+ data = tomllib.load(f)
198
+ except tomllib.TOMLDecodeError as e:
199
+ raise SyncConfigError(f"Invalid TOML syntax: {e}") from e
200
+
201
+ # Handle pyproject.toml with [tool.envdrift] structure
202
+ if path.name == "pyproject.toml":
203
+ tool_config = data.get("tool", {}).get("envdrift", {})
204
+ sync_data = tool_config.get("vault", {}).get("sync", {})
205
+ else:
206
+ # Standalone envdrift.toml or sync.toml
207
+ sync_data = data.get("vault", {}).get("sync", {})
208
+ # Also support top-level sync section for dedicated sync config files
209
+ if not sync_data and "mappings" in data:
210
+ sync_data = data
211
+
212
+ if not sync_data:
213
+ raise SyncConfigError(
214
+ f"No sync configuration found in {path}. "
215
+ "Expected [vault.sync] section with [[vault.sync.mappings]]"
216
+ )
217
+
218
+ return cls.from_toml(sync_data)