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,93 @@
1
+ """Helpers for resolving encryption backends from config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from envdrift.config import ConfigNotFoundError, find_config, load_config
11
+ from envdrift.encryption import EncryptionProvider, get_encryption_backend
12
+
13
+ if TYPE_CHECKING:
14
+ from envdrift.config import EncryptionConfig
15
+ from envdrift.encryption.base import EncryptionBackend
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def resolve_encryption_backend(
21
+ config_file: Path | None,
22
+ ) -> tuple[EncryptionBackend, EncryptionProvider, EncryptionConfig | None]:
23
+ """
24
+ Resolve the encryption backend using an explicit config file or auto-discovery.
25
+
26
+ Returns the instantiated backend, selected provider, and the encryption config
27
+ (if available).
28
+ """
29
+ config_path = None
30
+ if config_file is not None and config_file.suffix.lower() == ".toml":
31
+ config_path = config_file
32
+ elif config_file is None:
33
+ config_path = find_config()
34
+
35
+ envdrift_config = None
36
+ if config_path:
37
+ try:
38
+ envdrift_config = load_config(config_path)
39
+ except (ConfigNotFoundError, tomllib.TOMLDecodeError) as exc:
40
+ logger.warning("Failed to load config from %s: %s", config_path, exc)
41
+ envdrift_config = None
42
+
43
+ encryption_config = getattr(envdrift_config, "encryption", None) if envdrift_config else None
44
+ backend_name = encryption_config.backend if encryption_config else "dotenvx"
45
+ provider = EncryptionProvider(backend_name)
46
+
47
+ backend_config: dict[str, object] = {}
48
+ if provider == EncryptionProvider.DOTENVX:
49
+ backend_config["auto_install"] = (
50
+ encryption_config.dotenvx_auto_install if encryption_config else False
51
+ )
52
+ else:
53
+ backend_config["auto_install"] = (
54
+ encryption_config.sops_auto_install if encryption_config else False
55
+ )
56
+ if encryption_config:
57
+ if encryption_config.sops_config_file:
58
+ backend_config["config_file"] = encryption_config.sops_config_file
59
+ if encryption_config.sops_age_key_file:
60
+ backend_config["age_key_file"] = encryption_config.sops_age_key_file
61
+
62
+ backend = get_encryption_backend(provider, **backend_config)
63
+ return backend, provider, encryption_config
64
+
65
+
66
+ def build_sops_encrypt_kwargs(encryption_config: EncryptionConfig | None) -> dict[str, str]:
67
+ """Build SOPS encryption kwargs from config."""
68
+ if not encryption_config:
69
+ return {}
70
+
71
+ kwargs: dict[str, str] = {}
72
+ if encryption_config.sops_age_recipients:
73
+ kwargs["age_recipients"] = encryption_config.sops_age_recipients
74
+ if encryption_config.sops_kms_arn:
75
+ kwargs["kms_arn"] = encryption_config.sops_kms_arn
76
+ if encryption_config.sops_gcp_kms:
77
+ kwargs["gcp_kms"] = encryption_config.sops_gcp_kms
78
+ if encryption_config.sops_azure_kv:
79
+ kwargs["azure_kv"] = encryption_config.sops_azure_kv
80
+ return kwargs
81
+
82
+
83
+ def is_encrypted_content(
84
+ provider: EncryptionProvider,
85
+ backend: EncryptionBackend,
86
+ content: str,
87
+ ) -> bool:
88
+ """Determine if file content is encrypted for the selected backend."""
89
+ if backend.has_encrypted_header(content):
90
+ return True
91
+ if provider == EncryptionProvider.DOTENVX:
92
+ return "encrypted:" in content.lower()
93
+ return False
@@ -0,0 +1,75 @@
1
+ """Pre-commit hook command for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from envdrift.output.rich import console, print_error, print_success
10
+
11
+
12
+ def hook(
13
+ install: Annotated[
14
+ bool, typer.Option("--install", "-i", help="Install pre-commit hook")
15
+ ] = False,
16
+ show_config: Annotated[
17
+ bool, typer.Option("--config", help="Show pre-commit config snippet")
18
+ ] = False,
19
+ ) -> None:
20
+ """
21
+ Manage the pre-commit hook integration by showing a sample config or installing hooks.
22
+
23
+ When invoked with --config or without --install, prints a pre-commit configuration snippet for envdrift hooks.
24
+ When invoked with --install, attempts to install the hooks using the pre-commit integration and prints success on completion.
25
+
26
+ Parameters:
27
+ install (bool): If True, install the pre-commit hooks into the project (--install / -i).
28
+ show_config (bool): If True, print the sample pre-commit configuration snippet (--config).
29
+
30
+ Raises:
31
+ typer.Exit: If installation is requested but the pre-commit integration is unavailable.
32
+ """
33
+ if show_config or (not install):
34
+ hook_config = """# Add to .pre-commit-config.yaml
35
+ repos:
36
+ - repo: local
37
+ hooks:
38
+ - id: envdrift-validate
39
+ name: Validate env files
40
+ entry: envdrift validate --ci
41
+ language: system
42
+ files: ^\\.env\\.(production|staging|development)$
43
+ pass_filenames: true
44
+
45
+ - id: envdrift-encryption
46
+ name: Check env encryption
47
+ entry: envdrift encrypt --check
48
+ language: system
49
+ files: ^\\.env\\.(production|staging)$
50
+ pass_filenames: true
51
+
52
+ # Optional: Verify encryption keys match vault (prevents key drift)
53
+ # - id: envdrift-vault-verify
54
+ # name: Verify vault key can decrypt
55
+ # entry: envdrift decrypt --verify-vault -p azure --vault-url https://myvault.vault.azure.net --secret myapp-dotenvx-key --ci
56
+ # language: system
57
+ # files: ^\\.env\\.production$
58
+ # pass_filenames: true
59
+ """
60
+ console.print(hook_config)
61
+
62
+ if not install:
63
+ console.print("[dim]Use --install to add hooks to .pre-commit-config.yaml[/dim]")
64
+ return
65
+
66
+ if install:
67
+ try:
68
+ from envdrift.integrations.precommit import install_hooks
69
+
70
+ install_hooks()
71
+ print_success("Pre-commit hooks installed")
72
+ except ImportError:
73
+ print_error("Pre-commit integration not available")
74
+ console.print("Copy the config above to .pre-commit-config.yaml manually")
75
+ raise typer.Exit(code=1) from None
@@ -0,0 +1,117 @@
1
+ """Schema generation command for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from envdrift.core.encryption import EncryptionDetector
11
+ from envdrift.core.parser import EnvParser
12
+ from envdrift.output.rich import console, print_error, print_success
13
+
14
+
15
+ def init(
16
+ env_file: Annotated[
17
+ Path, typer.Argument(help="Path to .env file to generate schema from")
18
+ ] = Path(".env"),
19
+ output: Annotated[
20
+ Path, typer.Option("--output", "-o", help="Output file for Settings class")
21
+ ] = Path("settings.py"),
22
+ class_name: Annotated[
23
+ str, typer.Option("--class-name", "-c", help="Name for the Settings class")
24
+ ] = "Settings",
25
+ detect_sensitive: Annotated[
26
+ bool, typer.Option("--detect-sensitive", help="Auto-detect sensitive variables")
27
+ ] = True,
28
+ ) -> None:
29
+ """
30
+ Generate a Pydantic BaseSettings subclass from variables in an .env file.
31
+
32
+ Writes a Python module containing a Pydantic `BaseSettings` subclass with fields
33
+ inferred from the .env variables. Detected sensitive variables are annotated
34
+ with `json_schema_extra={"sensitive": True}` and fields without a sensible
35
+ default are left required.
36
+
37
+ Parameters:
38
+ env_file (Path): Path to the source .env file.
39
+ output (Path): Path to write the generated Python module (e.g., settings.py).
40
+ class_name (str): Name to use for the generated `BaseSettings` subclass.
41
+ detect_sensitive (bool): If true, attempt to auto-detect sensitive variables
42
+ (by name and value) and mark them in the generated fields.
43
+ """
44
+ if not env_file.exists():
45
+ print_error(f"ENV file not found: {env_file}")
46
+ raise typer.Exit(code=1)
47
+
48
+ # Parse env file
49
+ parser = EnvParser()
50
+ env = parser.parse(env_file)
51
+
52
+ # Detect sensitive variables if requested
53
+ detector = EncryptionDetector()
54
+ sensitive_vars = set()
55
+ if detect_sensitive:
56
+ for var_name, env_var in env.variables.items():
57
+ is_name_sens = detector.is_name_sensitive(var_name)
58
+ is_val_susp = detector.is_value_suspicious(env_var.value)
59
+ if is_name_sens or is_val_susp:
60
+ sensitive_vars.add(var_name)
61
+
62
+ # Generate settings class
63
+ lines = [
64
+ '"""Auto-generated Pydantic Settings class."""',
65
+ "",
66
+ "from pydantic import Field",
67
+ "from pydantic_settings import BaseSettings, SettingsConfigDict",
68
+ "",
69
+ "",
70
+ f"class {class_name}(BaseSettings):",
71
+ f' """Settings generated from {env_file}."""',
72
+ "",
73
+ " model_config = SettingsConfigDict(",
74
+ f' env_file="{env_file}",',
75
+ ' extra="forbid",',
76
+ " )",
77
+ "",
78
+ ]
79
+
80
+ for var_name, env_var in sorted(env.variables.items()):
81
+ is_sensitive = var_name in sensitive_vars
82
+
83
+ # Try to infer type from value
84
+ value = env_var.value
85
+ if value.lower() in ("true", "false"):
86
+ type_hint = "bool"
87
+ default_val = value.lower() == "true"
88
+ elif value.isdigit():
89
+ type_hint = "int"
90
+ default_val = int(value)
91
+ else:
92
+ type_hint = "str"
93
+ default_val = None # Will be required
94
+
95
+ # Build field
96
+ if is_sensitive:
97
+ extra = 'json_schema_extra={"sensitive": True}'
98
+ if default_val is not None:
99
+ lines.append(
100
+ f" {var_name}: {type_hint} = Field(default={default_val!r}, {extra})"
101
+ )
102
+ else:
103
+ lines.append(f" {var_name}: {type_hint} = Field({extra})")
104
+ else:
105
+ if default_val is not None:
106
+ lines.append(f" {var_name}: {type_hint} = {default_val!r}")
107
+ else:
108
+ lines.append(f" {var_name}: {type_hint}")
109
+
110
+ lines.append("")
111
+
112
+ # Write output
113
+ output.write_text("\n".join(lines))
114
+ print_success(f"Generated {output}")
115
+
116
+ if sensitive_vars:
117
+ console.print(f"[dim]Detected {len(sensitive_vars)} sensitive variable(s)[/dim]")
@@ -0,0 +1,222 @@
1
+ """CLI commands for partial encryption functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.panel import Panel
9
+
10
+ from envdrift.config import load_config
11
+ from envdrift.core.partial_encryption import (
12
+ PartialEncryptionError,
13
+ pull_partial_encryption,
14
+ push_partial_encryption,
15
+ )
16
+ from envdrift.output.rich import console, print_error, print_success, print_warning
17
+
18
+
19
+ def push(
20
+ env: Annotated[
21
+ str | None,
22
+ typer.Option("--env", "-e", help="Environment name (e.g., production, staging)"),
23
+ ] = None,
24
+ ) -> None:
25
+ """
26
+ Encrypt secret files and combine with clear files (prepare for commit).
27
+
28
+ This command:
29
+ 1. Encrypts .env.{env}.secret files using dotenvx
30
+ 2. Combines .env.{env}.clear + encrypted .secret → .env.{env}
31
+ 3. Adds warning header to generated file
32
+
33
+ The generated .env.{env} file should be committed to git.
34
+
35
+ Examples:
36
+ # Push all environments
37
+ envdrift push
38
+
39
+ # Push specific environment
40
+ envdrift push --env production
41
+ """
42
+ # Load config
43
+ try:
44
+ config = load_config()
45
+ except Exception as e:
46
+ print_error(f"Failed to load configuration: {e}")
47
+ raise typer.Exit(code=1) from None
48
+
49
+ if not config.partial_encryption.enabled:
50
+ print_error("Partial encryption is not enabled in configuration")
51
+ console.print("\nTo enable partial encryption, add to your envdrift.toml:")
52
+ console.print(
53
+ "[cyan][[partial_encryption.environments]][/cyan]\n"
54
+ '[cyan]name = "production"[/cyan]\n'
55
+ '[cyan]clear_file = ".env.production.clear"[/cyan]\n'
56
+ '[cyan]secret_file = ".env.production.secret"[/cyan]\n'
57
+ '[cyan]combined_file = ".env.production"[/cyan]'
58
+ )
59
+ raise typer.Exit(code=1)
60
+
61
+ # Filter environments
62
+ envs_to_process = config.partial_encryption.environments
63
+ if env:
64
+ envs_to_process = [e for e in envs_to_process if e.name == env]
65
+ if not envs_to_process:
66
+ print_error(f"No partial encryption configuration found for environment '{env}'")
67
+ raise typer.Exit(code=1)
68
+
69
+ console.print()
70
+ console.print("[bold]Push[/bold] - Encrypting and combining env files")
71
+ console.print(f"[dim]Environments: {len(envs_to_process)}[/dim]")
72
+ console.print()
73
+
74
+ total_encrypted = 0
75
+ total_combined = 0
76
+ errors = []
77
+
78
+ for env_config in envs_to_process:
79
+ console.print(f"[bold cyan]→[/bold cyan] {env_config.name}")
80
+
81
+ try:
82
+ stats = push_partial_encryption(env_config)
83
+
84
+ console.print(
85
+ f" [green]✓[/green] Generated {env_config.combined_file} "
86
+ f"[dim]({stats['clear_lines']} clear + {stats['secret_vars']} encrypted)[/dim]"
87
+ )
88
+
89
+ total_combined += 1
90
+ total_encrypted += stats["secret_vars"]
91
+
92
+ except PartialEncryptionError as e:
93
+ console.print(f" [red]✗[/red] {e}")
94
+ errors.append(f"{env_config.name}: {e}")
95
+ except Exception as e:
96
+ console.print(f" [red]✗[/red] Unexpected error: {e}")
97
+ errors.append(f"{env_config.name}: {e}")
98
+
99
+ # Summary
100
+ console.print()
101
+ summary_lines = [
102
+ f"Combined: {total_combined}/{len(envs_to_process)}",
103
+ f"Total encrypted vars: {total_encrypted}",
104
+ ]
105
+ if errors:
106
+ summary_lines.append(f"Errors: {len(errors)}")
107
+
108
+ console.print(Panel("\n".join(summary_lines), title="Push Summary", expand=False))
109
+
110
+ if errors:
111
+ console.print()
112
+ print_warning("Some environments had errors:")
113
+ for error in errors:
114
+ console.print(f" • {error}")
115
+ raise typer.Exit(code=1)
116
+
117
+ console.print()
118
+ print_success("Push complete! Combined files are ready to commit.")
119
+ console.print()
120
+ console.print(
121
+ "[dim]Remember to edit source files (.clear and .secret), not the combined file.[/dim]"
122
+ )
123
+
124
+
125
+ def pull_cmd(
126
+ env: Annotated[
127
+ str | None,
128
+ typer.Option("--env", "-e", help="Environment name (e.g., production, staging)"),
129
+ ] = None,
130
+ ) -> None:
131
+ """
132
+ Decrypt secret files for editing (pull operation).
133
+
134
+ This command:
135
+ 1. Decrypts .env.{env}.secret files in-place using dotenvx
136
+ 2. Makes them available for editing
137
+
138
+ After pulling, you can edit:
139
+ - .env.{env}.clear (non-sensitive variables)
140
+ - .env.{env}.secret (sensitive variables, now decrypted)
141
+
142
+ Run 'envdrift push' before committing to re-encrypt and combine.
143
+
144
+ Examples:
145
+ # Pull all environments
146
+ envdrift pull-partial
147
+
148
+ # Pull specific environment
149
+ envdrift pull-partial --env production
150
+ """
151
+ # Load config
152
+ try:
153
+ config = load_config()
154
+ except Exception as e:
155
+ print_error(f"Failed to load configuration: {e}")
156
+ raise typer.Exit(code=1) from None
157
+
158
+ if not config.partial_encryption.enabled:
159
+ print_error("Partial encryption is not enabled in configuration")
160
+ raise typer.Exit(code=1)
161
+
162
+ # Filter environments
163
+ envs_to_process = config.partial_encryption.environments
164
+ if env:
165
+ envs_to_process = [e for e in envs_to_process if e.name == env]
166
+ if not envs_to_process:
167
+ print_error(f"No partial encryption configuration found for environment '{env}'")
168
+ raise typer.Exit(code=1)
169
+
170
+ console.print()
171
+ console.print("[bold]Pull[/bold] - Decrypting secret files")
172
+ console.print(f"[dim]Environments: {len(envs_to_process)}[/dim]")
173
+ console.print()
174
+
175
+ decrypted_count = 0
176
+ skipped_count = 0
177
+ errors = []
178
+
179
+ for env_config in envs_to_process:
180
+ console.print(f"[bold cyan]→[/bold cyan] {env_config.name}")
181
+
182
+ try:
183
+ was_decrypted = pull_partial_encryption(env_config)
184
+
185
+ if was_decrypted:
186
+ console.print(f" [green]✓[/green] Decrypted {env_config.secret_file}")
187
+ decrypted_count += 1
188
+ else:
189
+ console.print(
190
+ f" [dim]=[/dim] {env_config.secret_file} [dim](already decrypted)[/dim]"
191
+ )
192
+ skipped_count += 1
193
+
194
+ except PartialEncryptionError as e:
195
+ console.print(f" [red]✗[/red] {e}")
196
+ errors.append(f"{env_config.name}: {e}")
197
+ except Exception as e:
198
+ console.print(f" [red]✗[/red] Unexpected error: {e}")
199
+ errors.append(f"{env_config.name}: {e}")
200
+
201
+ # Summary
202
+ console.print()
203
+ summary_lines = [
204
+ f"Decrypted: {decrypted_count}",
205
+ f"Skipped: {skipped_count}",
206
+ ]
207
+ if errors:
208
+ summary_lines.append(f"Errors: {len(errors)}")
209
+
210
+ console.print(Panel("\n".join(summary_lines), title="Pull Summary", expand=False))
211
+
212
+ if errors:
213
+ console.print()
214
+ print_warning("Some environments had errors:")
215
+ for error in errors:
216
+ console.print(f" • {error}")
217
+ raise typer.Exit(code=1)
218
+
219
+ console.print()
220
+ print_success("Pull complete! Secret files are now decrypted for editing.")
221
+ console.print()
222
+ console.print("[dim]Remember to run 'envdrift push' before committing.[/dim]")