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,109 @@
1
+ """Validation 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.parser import EnvParser
11
+ from envdrift.core.schema import SchemaLoader, SchemaLoadError
12
+ from envdrift.core.validator import Validator
13
+ from envdrift.output.rich import console, print_error, print_validation_result
14
+
15
+
16
+ def validate(
17
+ env_file: Annotated[Path, typer.Argument(help="Path to .env file to validate")] = Path(".env"),
18
+ schema: Annotated[
19
+ str | None,
20
+ typer.Option("--schema", "-s", help="Dotted path to Settings class"),
21
+ ] = None,
22
+ service_dir: Annotated[
23
+ Path | None,
24
+ typer.Option("--service-dir", "-d", help="Service directory for imports"),
25
+ ] = None,
26
+ ci: Annotated[bool, typer.Option("--ci", help="CI mode: exit with code 1 on failure")] = False,
27
+ check_encryption: Annotated[
28
+ bool,
29
+ typer.Option("--check-encryption/--no-check-encryption", help="Check encryption"),
30
+ ] = True,
31
+ fix: Annotated[
32
+ bool, typer.Option("--fix", help="Output template for missing variables")
33
+ ] = False,
34
+ verbose: Annotated[
35
+ bool, typer.Option("--verbose", "-v", help="Show additional details")
36
+ ] = False,
37
+ ) -> None:
38
+ """
39
+ Validate an .env file against a Pydantic Settings schema and display results.
40
+
41
+ Loads the specified Settings class, parses the given .env file, runs validation
42
+ (including optional encryption checks and extra-key checks), and prints a
43
+ human-readable validation report. If --fix is provided and validation fails,
44
+ prints a generated template for missing values. Exits with code 1 on invalid
45
+ schema or missing env file; when --ci is set, also exits with code 1 if the
46
+ validation result is invalid.
47
+
48
+ Parameters:
49
+ schema (str | None): Dotted import path to the Pydantic Settings class
50
+ (for example: "app.config:Settings"). Required; the command exits with
51
+ code 1 if not provided or if loading fails.
52
+ service_dir (Path | None): Optional directory to add to imports when
53
+ resolving the schema.
54
+ ci (bool): When true, exit with code 1 if validation fails.
55
+ check_encryption (bool): When true, validate encryption-related metadata
56
+ on sensitive fields.
57
+ fix (bool): When true and validation fails, print a fix template with
58
+ missing variables and defaults when available.
59
+ verbose (bool): When true, include additional details in the validation
60
+ output.
61
+ """
62
+ if schema is None:
63
+ print_error("--schema is required. Example: --schema 'app.config:Settings'")
64
+ raise typer.Exit(code=1)
65
+
66
+ # Check env file exists
67
+ if not env_file.exists():
68
+ print_error(f"ENV file not found: {env_file}")
69
+ raise typer.Exit(code=1)
70
+
71
+ # Load schema
72
+ loader = SchemaLoader()
73
+ try:
74
+ settings_cls = loader.load(schema, service_dir)
75
+ schema_meta = loader.extract_metadata(settings_cls)
76
+ except SchemaLoadError as e:
77
+ print_error(str(e))
78
+ raise typer.Exit(code=1) from None
79
+
80
+ # Parse env file
81
+ parser = EnvParser()
82
+ try:
83
+ env = parser.parse(env_file)
84
+ except FileNotFoundError as e:
85
+ print_error(str(e))
86
+ raise typer.Exit(code=1) from None
87
+
88
+ # Validate
89
+ validator = Validator()
90
+ result = validator.validate(
91
+ env,
92
+ schema_meta,
93
+ check_encryption=check_encryption,
94
+ check_extra=True,
95
+ )
96
+
97
+ # Print result
98
+ print_validation_result(result, env_file, schema_meta, verbose=verbose)
99
+
100
+ # Generate fix template if requested
101
+ if fix and not result.valid:
102
+ template = validator.generate_fix_template(result, schema_meta)
103
+ if template:
104
+ console.print("[bold]Fix template:[/bold]")
105
+ console.print(template)
106
+
107
+ # Exit with appropriate code
108
+ if ci and not result.valid:
109
+ raise typer.Exit(code=1)
@@ -0,0 +1,376 @@
1
+ """Vault operations 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.env_files import detect_env_file
11
+ from envdrift.output.rich import console, print_error, print_success, print_warning
12
+
13
+
14
+ def vault_push(
15
+ folder: Annotated[
16
+ Path | None,
17
+ typer.Argument(help="Service folder containing .env.keys file"),
18
+ ] = None,
19
+ secret_name: Annotated[
20
+ str | None,
21
+ typer.Argument(help="Name of the secret in the vault"),
22
+ ] = None,
23
+ env: Annotated[
24
+ str | None,
25
+ typer.Option(
26
+ "--env", "-e", help="Environment suffix (e.g., 'soak' for DOTENV_PRIVATE_KEY_SOAK)"
27
+ ),
28
+ ] = None,
29
+ direct: Annotated[
30
+ bool,
31
+ typer.Option(
32
+ "--direct",
33
+ help="Push a direct key-value pair (use with positional args: secret-name value)",
34
+ ),
35
+ ] = False,
36
+ all_services: Annotated[
37
+ bool,
38
+ typer.Option("--all", help="Push all secrets defined in sync config (skipping existing)"),
39
+ ] = False,
40
+ skip_encrypt: Annotated[
41
+ bool,
42
+ typer.Option("--skip-encrypt", help="Skip encryption step, only push keys to vault"),
43
+ ] = False,
44
+ config: Annotated[
45
+ Path | None,
46
+ typer.Option("--config", "-c", help="Path to sync config file"),
47
+ ] = None,
48
+ provider: Annotated[
49
+ str | None,
50
+ typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
51
+ ] = None,
52
+ vault_url: Annotated[
53
+ str | None,
54
+ typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
55
+ ] = None,
56
+ region: Annotated[
57
+ str | None,
58
+ typer.Option("--region", help="AWS region (default: us-east-1)"),
59
+ ] = None,
60
+ project_id: Annotated[
61
+ str | None,
62
+ typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
63
+ ] = None,
64
+ ) -> None:
65
+ """
66
+ Push encryption keys from local .env.keys files to cloud vaults.
67
+
68
+ This is the reverse of `envdrift sync` - uploads local keys to vault.
69
+
70
+ Three modes:
71
+
72
+ 1. From .env.keys file (Single Service):
73
+ envdrift vault-push ./services/soak soak-machine --env soak
74
+
75
+ 2. Direct value:
76
+ envdrift vault-push --direct soak-machine "DOTENV_PRIVATE_KEY_SOAK=abc123..."
77
+
78
+ 3. All Services (from config):
79
+ envdrift vault-push --all
80
+
81
+ Examples:
82
+ # Push from .env.keys (reads DOTENV_PRIVATE_KEY_SOAK)
83
+ envdrift vault-push ./services/soak soak-machine --env soak -p azure --vault-url https://myvault.vault.azure.net/
84
+
85
+ # Push direct value
86
+ envdrift vault-push --direct soak-machine "DOTENV_PRIVATE_KEY_SOAK=abc..." -p azure --vault-url https://myvault.vault.azure.net/
87
+
88
+ # Push all missing secrets defined in config
89
+ envdrift vault-push --all
90
+
91
+ # Push all without encrypting (when files are already encrypted)
92
+ envdrift vault-push --all --skip-encrypt
93
+ """
94
+ import contextlib
95
+ import tomllib
96
+
97
+ from envdrift.config import ConfigNotFoundError, find_config, load_config
98
+ from envdrift.sync.operations import EnvKeysFile
99
+ from envdrift.vault import VaultError, get_vault_client
100
+ from envdrift.vault.base import SecretNotFoundError
101
+
102
+ # Validate --skip-encrypt is only used with --all
103
+ if skip_encrypt and not all_services:
104
+ print_warning("--skip-encrypt is only applicable with --all mode, ignoring")
105
+
106
+ # --all mode implementation
107
+ if all_services:
108
+ from envdrift.cli_commands.encryption_helpers import (
109
+ build_sops_encrypt_kwargs,
110
+ is_encrypted_content,
111
+ resolve_encryption_backend,
112
+ )
113
+ from envdrift.cli_commands.sync import load_sync_config_and_client
114
+ from envdrift.encryption import (
115
+ EncryptionBackendError,
116
+ EncryptionNotFoundError,
117
+ EncryptionProvider,
118
+ detect_encryption_provider,
119
+ )
120
+
121
+ # Load sync config and client
122
+ sync_config, client, effective_provider, _, _, _ = load_sync_config_and_client(
123
+ config_file=config,
124
+ provider=provider,
125
+ vault_url=vault_url,
126
+ region=region,
127
+ project_id=project_id,
128
+ )
129
+
130
+ try:
131
+ encryption_backend, backend_provider, encryption_config = resolve_encryption_backend(
132
+ config
133
+ )
134
+ except ValueError as e:
135
+ print_error(f"Unsupported encryption backend: {e}")
136
+ raise typer.Exit(code=1) from None
137
+
138
+ if not encryption_backend.is_installed():
139
+ print_error(f"{encryption_backend.name} is not installed")
140
+ console.print(encryption_backend.install_instructions())
141
+ raise typer.Exit(code=1)
142
+
143
+ sops_encrypt_kwargs = {}
144
+ if backend_provider == EncryptionProvider.SOPS:
145
+ sops_encrypt_kwargs = build_sops_encrypt_kwargs(encryption_config)
146
+
147
+ console.print("[bold]Vault Push All[/bold]")
148
+ console.print(f"Provider: {effective_provider}")
149
+ console.print(f"Services: {len(sync_config.mappings)}")
150
+ if skip_encrypt:
151
+ console.print("[dim]Encryption: skipped (--skip-encrypt)[/dim]")
152
+ console.print()
153
+
154
+ pushed_count = 0
155
+ skipped_count = 0
156
+ error_count = 0
157
+ dotenvx_mismatch = False
158
+
159
+ for mapping in sync_config.mappings:
160
+ try:
161
+ # Check/Detect .env file (unless --skip-encrypt, where we only need .env.keys)
162
+ env_file = mapping.folder_path / f".env.{mapping.effective_environment}"
163
+ effective_environment = mapping.effective_environment
164
+
165
+ if not skip_encrypt:
166
+ if not env_file.exists():
167
+ # Auto-detect logic similar to sync
168
+ detected = detect_env_file(mapping.folder_path)
169
+ if detected.status == "found" and detected.path:
170
+ env_file = detected.path
171
+ if detected.environment:
172
+ effective_environment = detected.environment
173
+
174
+ if not env_file.exists():
175
+ console.print(
176
+ f"[dim]Skipped[/dim] {mapping.folder_path}: No .env file found"
177
+ )
178
+ skipped_count += 1
179
+ continue
180
+
181
+ # Check encryption (unless --skip-encrypt)
182
+ if not skip_encrypt:
183
+ content = env_file.read_text()
184
+ if not is_encrypted_content(backend_provider, encryption_backend, content):
185
+ detected_provider = detect_encryption_provider(env_file)
186
+ if detected_provider and detected_provider != backend_provider:
187
+ if (
188
+ detected_provider == EncryptionProvider.DOTENVX
189
+ and backend_provider != EncryptionProvider.DOTENVX
190
+ ):
191
+ print_error(
192
+ f"{env_file}: encrypted with dotenvx, "
193
+ f"but config uses {backend_provider.value}"
194
+ )
195
+ error_count += 1
196
+ dotenvx_mismatch = True
197
+ continue
198
+ console.print(
199
+ f"[dim]Skipped[/dim] {mapping.folder_path}: "
200
+ f"Encrypted with {detected_provider.value}, "
201
+ f"config uses {backend_provider.value}"
202
+ )
203
+ skipped_count += 1
204
+ continue
205
+
206
+ console.print(f"Encrypting {env_file} with {encryption_backend.name}...")
207
+ try:
208
+ result = encryption_backend.encrypt(env_file, **sops_encrypt_kwargs)
209
+ if not result.success:
210
+ print_error(result.message)
211
+ error_count += 1
212
+ continue
213
+ except (EncryptionNotFoundError, EncryptionBackendError) as e:
214
+ print_error(f"Failed to encrypt {env_file}: {e}")
215
+ error_count += 1
216
+ continue
217
+
218
+ # Check if secret exists in vault
219
+ try:
220
+ client.get_secret(mapping.secret_name)
221
+ # If successful, secret exists
222
+ console.print(
223
+ f"[dim]Skipped[/dim] {mapping.folder_path}: Secret '{mapping.secret_name}' already exists"
224
+ )
225
+ skipped_count += 1
226
+ continue
227
+ except SecretNotFoundError:
228
+ # Secret missing, proceed to push
229
+ pass
230
+ except VaultError as e:
231
+ print_error(f"Vault error checking {mapping.secret_name}: {e}")
232
+ error_count += 1
233
+ continue
234
+
235
+ # Read key to push
236
+ env_keys_path = mapping.folder_path / sync_config.env_keys_filename
237
+ if not env_keys_path.exists():
238
+ print_error(f"Skipped {mapping.folder_path}: .env.keys not found")
239
+ error_count += 1
240
+ continue
241
+
242
+ env_keys = EnvKeysFile(env_keys_path)
243
+ key_name = f"DOTENV_PRIVATE_KEY_{effective_environment.upper()}"
244
+ key_value = env_keys.read_key(key_name)
245
+
246
+ if not key_value:
247
+ print_error(f"Skipped {mapping.folder_path}: {key_name} not found in keys file")
248
+ error_count += 1
249
+ continue
250
+
251
+ actual_value = f"{key_name}={key_value}"
252
+
253
+ # Push
254
+ client.set_secret(mapping.secret_name, actual_value)
255
+ print_success(f"Pushed {mapping.secret_name}")
256
+ pushed_count += 1
257
+
258
+ except (VaultError, OSError, ValueError) as e:
259
+ print_error(f"Error processing {mapping.folder_path}: {e}")
260
+ error_count += 1
261
+
262
+ console.print()
263
+ console.print(
264
+ f"Done. Pushed: {pushed_count}, Skipped: {skipped_count}, Errors: {error_count}"
265
+ )
266
+ if dotenvx_mismatch:
267
+ raise typer.Exit(code=1)
268
+ return
269
+
270
+ # Normal/Direct mode preamble
271
+ envdrift_config = None
272
+ if config:
273
+ with contextlib.suppress(ConfigNotFoundError, tomllib.TOMLDecodeError):
274
+ envdrift_config = load_config(config)
275
+ else:
276
+ config_path = find_config()
277
+ if config_path:
278
+ with contextlib.suppress(ConfigNotFoundError, tomllib.TOMLDecodeError):
279
+ envdrift_config = load_config(config_path)
280
+
281
+ vault_config = getattr(envdrift_config, "vault", None)
282
+
283
+ # Determine effective provider
284
+ effective_provider = provider or getattr(vault_config, "provider", None)
285
+ if not effective_provider:
286
+ print_error("Vault provider required. Use --provider or configure in envdrift.toml")
287
+ raise typer.Exit(code=1)
288
+
289
+ # Determine effective vault URL
290
+ effective_vault_url = vault_url
291
+ if effective_vault_url is None and vault_config:
292
+ if effective_provider == "azure":
293
+ effective_vault_url = getattr(vault_config, "azure_vault_url", None)
294
+ elif effective_provider == "hashicorp":
295
+ effective_vault_url = getattr(vault_config, "hashicorp_url", None)
296
+
297
+ # Determine effective region
298
+ effective_region = region
299
+ if effective_region is None and vault_config:
300
+ effective_region = getattr(vault_config, "aws_region", None)
301
+
302
+ effective_project_id = project_id
303
+ if effective_project_id is None and vault_config:
304
+ effective_project_id = getattr(vault_config, "gcp_project_id", None)
305
+
306
+ # Validate provider-specific requirements
307
+ if effective_provider in ("azure", "hashicorp") and not effective_vault_url:
308
+ print_error(f"--vault-url required for {effective_provider}")
309
+ raise typer.Exit(code=1)
310
+ if effective_provider == "gcp" and not effective_project_id:
311
+ print_error("--project-id required for gcp")
312
+ raise typer.Exit(code=1)
313
+
314
+ # Handle direct mode
315
+ if direct:
316
+ if not folder or not secret_name:
317
+ print_error("Direct mode requires: envdrift vault-push --direct <secret-name> <value>")
318
+ raise typer.Exit(code=1)
319
+ # In direct mode, folder is actually the secret name, secret_name is the value
320
+ actual_secret_name = str(folder)
321
+ actual_value = secret_name
322
+ else:
323
+ # Normal mode: read from .env.keys
324
+ if not folder or not secret_name or not env:
325
+ print_error(
326
+ "Required: envdrift vault-push <folder> <secret-name> --env <environment> (or use --all)"
327
+ )
328
+ raise typer.Exit(code=1)
329
+
330
+ # Read the key from .env.keys
331
+ env_keys_path = folder / ".env.keys"
332
+ if not env_keys_path.exists():
333
+ print_error(f"File not found: {env_keys_path}")
334
+ raise typer.Exit(code=1)
335
+
336
+ env_keys = EnvKeysFile(env_keys_path)
337
+ key_name = f"DOTENV_PRIVATE_KEY_{env.upper()}"
338
+ key_value = env_keys.read_key(key_name)
339
+
340
+ if not key_value:
341
+ print_error(f"Key '{key_name}' not found in {env_keys_path}")
342
+ raise typer.Exit(code=1)
343
+
344
+ actual_secret_name = secret_name
345
+ actual_value = f"{key_name}={key_value}"
346
+
347
+ # Create vault client
348
+ try:
349
+ vault_client_config = {}
350
+ if effective_provider == "azure":
351
+ vault_client_config["vault_url"] = effective_vault_url
352
+ elif effective_provider == "aws":
353
+ vault_client_config["region"] = effective_region or "us-east-1"
354
+ elif effective_provider == "hashicorp":
355
+ vault_client_config["url"] = effective_vault_url
356
+ elif effective_provider == "gcp":
357
+ vault_client_config["project_id"] = effective_project_id
358
+
359
+ client = get_vault_client(effective_provider, **vault_client_config)
360
+ client.authenticate()
361
+ except ImportError as e:
362
+ print_error(str(e))
363
+ raise typer.Exit(code=1) from None
364
+ except VaultError as e:
365
+ print_error(f"Vault authentication failed: {e}")
366
+ raise typer.Exit(code=1) from None
367
+
368
+ # Push the secret
369
+ try:
370
+ result = client.set_secret(actual_secret_name, actual_value)
371
+ print_success(f"Pushed secret '{actual_secret_name}' to {effective_provider} vault")
372
+ if result.version:
373
+ console.print(f" Version: {result.version}")
374
+ except VaultError as e:
375
+ print_error(f"Failed to push secret: {e}")
376
+ raise typer.Exit(code=1) from None
@@ -0,0 +1,15 @@
1
+ """Version command for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from envdrift import __version__
6
+ from envdrift.output.rich import console
7
+
8
+
9
+ def version() -> None:
10
+ """
11
+ Display the installed envdrift version in the console.
12
+
13
+ Prints the current package version using the application's styled console output.
14
+ """
15
+ console.print(f"envdrift [bold green]{__version__}[/bold green]")