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
envdrift/config.py ADDED
@@ -0,0 +1,489 @@
1
+ """Configuration loader for envdrift.toml."""
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
+ @dataclass
12
+ class SyncMappingConfig:
13
+ """Sync mapping configuration for vault key synchronization."""
14
+
15
+ secret_name: str
16
+ folder_path: str
17
+ vault_name: str | None = None
18
+ environment: str | None = None # None = derive from profile or default to "production"
19
+ profile: str | None = None # Profile name for filtering (e.g., "local", "prod")
20
+ activate_to: str | None = None # Path to copy decrypted file when profile is activated
21
+
22
+
23
+ @dataclass
24
+ class SyncConfig:
25
+ """Sync-specific configuration."""
26
+
27
+ mappings: list[SyncMappingConfig] = field(default_factory=list)
28
+ default_vault_name: str | None = None
29
+ env_keys_filename: str = ".env.keys"
30
+
31
+
32
+ @dataclass
33
+ class VaultConfig:
34
+ """Vault-specific configuration."""
35
+
36
+ provider: str = "azure" # azure, aws, hashicorp, gcp
37
+ azure_vault_url: str | None = None
38
+ aws_region: str = "us-east-1"
39
+ hashicorp_url: str | None = None
40
+ gcp_project_id: str | None = None
41
+ mappings: dict[str, str] = field(default_factory=dict)
42
+ sync: SyncConfig = field(default_factory=SyncConfig)
43
+
44
+
45
+ @dataclass
46
+ class EncryptionConfig:
47
+ """Encryption backend settings."""
48
+
49
+ # Encryption backend: dotenvx (default) or sops
50
+ backend: str = "dotenvx"
51
+
52
+ # dotenvx-specific settings
53
+ dotenvx_auto_install: bool = False
54
+
55
+ # SOPS-specific settings
56
+ sops_auto_install: bool = False
57
+ sops_config_file: str | None = None # Path to .sops.yaml
58
+ sops_age_key_file: str | None = None # Path to age key file
59
+ sops_age_recipients: str | None = None # Age public key(s) for encryption
60
+ sops_kms_arn: str | None = None # AWS KMS key ARN
61
+ sops_gcp_kms: str | None = None # GCP KMS resource ID
62
+ sops_azure_kv: str | None = None # Azure Key Vault key URL
63
+
64
+
65
+ @dataclass
66
+ class ValidationConfig:
67
+ """Validation settings."""
68
+
69
+ check_encryption: bool = True
70
+ strict_extra: bool = True
71
+ secret_patterns: list[str] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class PrecommitConfig:
76
+ """Pre-commit hook settings."""
77
+
78
+ files: list[str] = field(default_factory=list)
79
+ schemas: dict[str, str] = field(default_factory=dict)
80
+
81
+
82
+ @dataclass
83
+ class PartialEncryptionEnvironmentConfig:
84
+ """Partial encryption configuration for a single environment."""
85
+
86
+ name: str
87
+ clear_file: str
88
+ secret_file: str
89
+ combined_file: str
90
+
91
+
92
+ @dataclass
93
+ class PartialEncryptionConfig:
94
+ """Partial encryption settings."""
95
+
96
+ enabled: bool = False
97
+ environments: list[PartialEncryptionEnvironmentConfig] = field(default_factory=list)
98
+
99
+
100
+ @dataclass
101
+ class EnvdriftConfig:
102
+ """Complete envdrift configuration."""
103
+
104
+ # Core settings
105
+ schema: str | None = None
106
+ environments: list[str] = field(
107
+ default_factory=lambda: ["development", "staging", "production"]
108
+ )
109
+ env_file_pattern: str = ".env.{environment}"
110
+
111
+ # Sub-configs
112
+ validation: ValidationConfig = field(default_factory=ValidationConfig)
113
+ vault: VaultConfig = field(default_factory=VaultConfig)
114
+ encryption: EncryptionConfig = field(default_factory=EncryptionConfig)
115
+ precommit: PrecommitConfig = field(default_factory=PrecommitConfig)
116
+ partial_encryption: PartialEncryptionConfig = field(default_factory=PartialEncryptionConfig)
117
+
118
+ # Raw config for access to custom fields
119
+ raw: dict[str, Any] = field(default_factory=dict)
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict[str, Any]) -> EnvdriftConfig:
123
+ """
124
+ Builds an EnvdriftConfig from a configuration dictionary.
125
+
126
+ Parses top-level sections (expected keys: "envdrift", "validation", "vault", "encryption", "precommit"), applies sensible defaults for missing fields, and returns a populated EnvdriftConfig with the original dictionary stored in `raw`.
127
+
128
+ Parameters:
129
+ data (dict[str, Any]): Parsed TOML/pyproject data containing configuration sections.
130
+
131
+ Returns:
132
+ EnvdriftConfig: Configuration object populated from `data`.
133
+ """
134
+ envdrift_section = data.get("envdrift", {})
135
+ validation_section = data.get("validation", {})
136
+ vault_section = data.get("vault", {})
137
+ encryption_section = data.get("encryption", {})
138
+ precommit_section = data.get("precommit", {})
139
+
140
+ # Build validation config
141
+ validation = ValidationConfig(
142
+ check_encryption=validation_section.get("check_encryption", True),
143
+ strict_extra=validation_section.get("strict_extra", True),
144
+ secret_patterns=validation_section.get("secret_patterns", []),
145
+ )
146
+
147
+ # Build sync config from vault.sync section
148
+ sync_section = vault_section.get("sync", {})
149
+ sync_mappings = [
150
+ SyncMappingConfig(
151
+ secret_name=m["secret_name"],
152
+ folder_path=m["folder_path"],
153
+ vault_name=m.get("vault_name"),
154
+ environment=m.get("environment"), # None = derive from profile
155
+ profile=m.get("profile"),
156
+ activate_to=m.get("activate_to"),
157
+ )
158
+ for m in sync_section.get("mappings", [])
159
+ ]
160
+ sync_config = SyncConfig(
161
+ mappings=sync_mappings,
162
+ default_vault_name=sync_section.get("default_vault_name"),
163
+ env_keys_filename=sync_section.get("env_keys_filename", ".env.keys"),
164
+ )
165
+
166
+ # Build vault config
167
+ vault = VaultConfig(
168
+ provider=vault_section.get("provider", "azure"),
169
+ azure_vault_url=vault_section.get("azure", {}).get("vault_url"),
170
+ aws_region=vault_section.get("aws", {}).get("region", "us-east-1"),
171
+ hashicorp_url=vault_section.get("hashicorp", {}).get("url"),
172
+ gcp_project_id=vault_section.get("gcp", {}).get("project_id"),
173
+ mappings=vault_section.get("mappings", {}),
174
+ sync=sync_config,
175
+ )
176
+
177
+ # Build precommit config
178
+ precommit = PrecommitConfig(
179
+ files=precommit_section.get("files", []),
180
+ schemas=precommit_section.get("schemas", {}),
181
+ )
182
+
183
+ # Build partial_encryption config
184
+ partial_encryption_section = data.get("partial_encryption", {})
185
+ partial_encryption_envs = [
186
+ PartialEncryptionEnvironmentConfig(
187
+ name=env["name"],
188
+ clear_file=env["clear_file"],
189
+ secret_file=env["secret_file"],
190
+ combined_file=env["combined_file"],
191
+ )
192
+ for env in partial_encryption_section.get("environments", [])
193
+ ]
194
+ partial_encryption = PartialEncryptionConfig(
195
+ enabled=partial_encryption_section.get("enabled", False),
196
+ environments=partial_encryption_envs,
197
+ )
198
+
199
+ # Build encryption config
200
+ sops_section = encryption_section.get("sops", {})
201
+ dotenvx_section = encryption_section.get("dotenvx", {})
202
+ encryption = EncryptionConfig(
203
+ backend=encryption_section.get("backend", "dotenvx"),
204
+ dotenvx_auto_install=dotenvx_section.get("auto_install", False),
205
+ sops_auto_install=sops_section.get("auto_install", False),
206
+ sops_config_file=sops_section.get("config_file"),
207
+ sops_age_key_file=sops_section.get("age_key_file"),
208
+ sops_age_recipients=sops_section.get("age_recipients"),
209
+ sops_kms_arn=sops_section.get("kms_arn"),
210
+ sops_gcp_kms=sops_section.get("gcp_kms"),
211
+ sops_azure_kv=sops_section.get("azure_kv"),
212
+ )
213
+
214
+ return cls(
215
+ schema=envdrift_section.get("schema"),
216
+ environments=envdrift_section.get(
217
+ "environments", ["development", "staging", "production"]
218
+ ),
219
+ env_file_pattern=envdrift_section.get("env_file_pattern", ".env.{environment}"),
220
+ validation=validation,
221
+ vault=vault,
222
+ encryption=encryption,
223
+ precommit=precommit,
224
+ partial_encryption=partial_encryption,
225
+ raw=data,
226
+ )
227
+
228
+
229
+ class ConfigNotFoundError(Exception):
230
+ """Configuration file not found."""
231
+
232
+ pass
233
+
234
+
235
+ def find_config(start_dir: Path | None = None, filename: str = "envdrift.toml") -> Path | None:
236
+ """
237
+ Locate an envdrift configuration file by searching the given directory and its parents.
238
+
239
+ Searches each directory from start_dir (defaults to the current working directory) up to the filesystem root for a file named by `filename`. If no such file is found, also checks each directory's pyproject.toml for a top-level [tool.envdrift] section and returns that pyproject path when present.
240
+
241
+ Parameters:
242
+ start_dir (Path | None): Directory to start searching from; defaults to the current working directory.
243
+ filename (str): Configuration filename to look for (default "envdrift.toml").
244
+
245
+ Returns:
246
+ Path | None: Path to the first matching configuration file or pyproject.toml containing [tool.envdrift], or `None` if none is found.
247
+ """
248
+ if start_dir is None:
249
+ start_dir = Path.cwd()
250
+
251
+ current = start_dir.resolve()
252
+
253
+ while current != current.parent:
254
+ config_path = current / filename
255
+ if config_path.exists():
256
+ return config_path
257
+
258
+ # Also check pyproject.toml for [tool.envdrift] section
259
+ pyproject = current / "pyproject.toml"
260
+ if pyproject.exists():
261
+ try:
262
+ with open(pyproject, "rb") as f:
263
+ data = tomllib.load(f)
264
+ if "tool" in data and "envdrift" in data["tool"]:
265
+ return pyproject
266
+ except (OSError, tomllib.TOMLDecodeError):
267
+ # Skip malformed or unreadable pyproject.toml files
268
+ pass
269
+
270
+ current = current.parent
271
+
272
+ return None
273
+
274
+
275
+ def load_config(path: Path | str | None = None) -> EnvdriftConfig:
276
+ """Load configuration from envdrift.toml or pyproject.toml.
277
+
278
+ Args:
279
+ path: Path to config file (auto-detected if None)
280
+
281
+ Returns:
282
+ EnvdriftConfig instance
283
+
284
+ Raises:
285
+ ConfigNotFoundError: If config file not found and path was specified
286
+ """
287
+ if path is not None:
288
+ path = Path(path)
289
+ if not path.exists():
290
+ raise ConfigNotFoundError(f"Configuration file not found: {path}")
291
+ else:
292
+ path = find_config()
293
+ if path is None:
294
+ # Return default config if no file found
295
+ return EnvdriftConfig()
296
+
297
+ with open(path, "rb") as f:
298
+ data = tomllib.load(f)
299
+
300
+ # Check if this is pyproject.toml with [tool.envdrift]
301
+ if path.name == "pyproject.toml":
302
+ tool_config = data.get("tool", {}).get("envdrift", {})
303
+ if tool_config:
304
+ # Restructure to expected format (copy to avoid mutating original)
305
+ envdrift_section = dict(tool_config)
306
+ data = {"envdrift": envdrift_section}
307
+ if "validation" in envdrift_section:
308
+ data["validation"] = envdrift_section.get("validation")
309
+ del envdrift_section["validation"]
310
+ if "vault" in envdrift_section:
311
+ data["vault"] = envdrift_section.get("vault")
312
+ del envdrift_section["vault"]
313
+ if "encryption" in envdrift_section:
314
+ data["encryption"] = envdrift_section.get("encryption")
315
+ del envdrift_section["encryption"]
316
+ if "precommit" in envdrift_section:
317
+ data["precommit"] = envdrift_section.get("precommit")
318
+ del envdrift_section["precommit"]
319
+ if "partial_encryption" in envdrift_section:
320
+ data["partial_encryption"] = envdrift_section.get("partial_encryption")
321
+ del envdrift_section["partial_encryption"]
322
+
323
+ return EnvdriftConfig.from_dict(data)
324
+
325
+
326
+ def get_env_file_path(config: EnvdriftConfig, environment: str) -> Path:
327
+ """
328
+ Build the Path to the .env file for the given environment using the configuration's env_file_pattern.
329
+
330
+ Parameters:
331
+ config (EnvdriftConfig): Configuration whose env_file_pattern will be formatted.
332
+ environment (str): Environment name inserted into the pattern (replaces `{environment}`).
333
+
334
+ Returns:
335
+ Path: Path to the computed .env file.
336
+ """
337
+ filename = config.env_file_pattern.format(environment=environment)
338
+ return Path(filename)
339
+
340
+
341
+ def get_schema_for_environment(config: EnvdriftConfig, environment: str) -> str | None:
342
+ """
343
+ Resolve the schema path to use for a given environment.
344
+
345
+ Prefers an environment-specific precommit schema when configured; otherwise returns the default schema from the config.
346
+
347
+ Returns:
348
+ The schema path for `environment`, or `None` if no schema is configured.
349
+ """
350
+ # Check for environment-specific schema
351
+ env_schema = config.precommit.schemas.get(environment)
352
+ if env_schema:
353
+ return env_schema
354
+
355
+ # Fall back to default schema
356
+ return config.schema
357
+
358
+
359
+ # Example config file content
360
+ EXAMPLE_CONFIG = """# envdrift.toml - Project configuration
361
+
362
+ [envdrift]
363
+ # Default schema for validation
364
+ schema = "config.settings:ProductionSettings"
365
+
366
+ # Environments to manage
367
+ environments = ["development", "staging", "production"]
368
+
369
+ # Path pattern for env files
370
+ env_file_pattern = ".env.{environment}"
371
+
372
+ [validation]
373
+ # Check encryption by default
374
+ check_encryption = true
375
+
376
+ # Treat extra vars as errors (matches Pydantic extra="forbid")
377
+ strict_extra = true
378
+
379
+ # Additional secret detection patterns
380
+ secret_patterns = [
381
+ "^STRIPE_",
382
+ "^TWILIO_",
383
+ ]
384
+
385
+ [encryption]
386
+ # Encryption backend: dotenvx (default) or sops
387
+ backend = "dotenvx"
388
+
389
+ # dotenvx-specific settings
390
+ [encryption.dotenvx]
391
+ auto_install = false
392
+
393
+ # SOPS-specific settings (only used when backend = "sops")
394
+ [encryption.sops]
395
+ auto_install = false
396
+ # config_file = ".sops.yaml" # Path to SOPS configuration
397
+ # age_key_file = "key.txt" # Path to age private key file
398
+ # age_recipients = "age1..." # Age public key(s) for encryption
399
+ # kms_arn = "arn:aws:kms:..." # AWS KMS key ARN
400
+ # gcp_kms = "projects/..." # GCP KMS resource ID
401
+ # azure_kv = "https://..." # Azure Key Vault key URL
402
+
403
+ [vault]
404
+ # Vault provider: azure, aws, hashicorp, gcp
405
+ provider = "azure"
406
+
407
+ [vault.azure]
408
+ vault_url = "https://my-vault.vault.azure.net/"
409
+
410
+ [vault.aws]
411
+ region = "us-east-1"
412
+
413
+ [vault.hashicorp]
414
+ url = "https://vault.example.com:8200"
415
+
416
+ [vault.gcp]
417
+ project_id = "my-gcp-project"
418
+ # token from VAULT_TOKEN env var
419
+
420
+ # Sync configuration for `envdrift sync` command
421
+ [vault.sync]
422
+ default_vault_name = "my-keyvault"
423
+ env_keys_filename = ".env.keys"
424
+
425
+ # Map vault secrets to local service directories
426
+ [[vault.sync.mappings]]
427
+ secret_name = "myapp-dotenvx-key"
428
+ folder_path = "."
429
+ environment = "production"
430
+
431
+ [[vault.sync.mappings]]
432
+ secret_name = "service2-dotenvx-key"
433
+ folder_path = "services/service2"
434
+ vault_name = "other-vault" # Optional: override default vault
435
+ environment = "staging"
436
+
437
+ # Profile mappings - use with `envdrift pull --profile local`
438
+ [[vault.sync.mappings]]
439
+ secret_name = "local-key"
440
+ folder_path = "."
441
+ profile = "local" # Tag for --profile filtering
442
+ activate_to = ".env" # Copy decrypted .env.local to .env
443
+
444
+ [precommit]
445
+ # Files to validate on commit
446
+ files = [
447
+ ".env.production",
448
+ ".env.staging",
449
+ ]
450
+
451
+ # Schema per environment (optional override)
452
+ [precommit.schemas]
453
+ production = "config.settings:ProductionSettings"
454
+ staging = "config.settings:StagingSettings"
455
+
456
+ # Partial encryption configuration (optional)
457
+ [partial_encryption]
458
+ enabled = false
459
+
460
+ # Configure environments for partial encryption
461
+ # [[partial_encryption.environments]]
462
+ # name = "production"
463
+ # clear_file = ".env.production.clear"
464
+ # secret_file = ".env.production.secret"
465
+ # combined_file = ".env.production"
466
+ """
467
+
468
+
469
+ def create_example_config(path: Path | None = None) -> Path:
470
+ """
471
+ Create an example envdrift.toml configuration file at the given path.
472
+
473
+ Parameters:
474
+ path (Path | None): Destination path for the example config. If None, defaults to "./envdrift.toml".
475
+
476
+ Returns:
477
+ Path: The path to the created configuration file.
478
+
479
+ Raises:
480
+ FileExistsError: If a file already exists at the target path.
481
+ """
482
+ if path is None:
483
+ path = Path("envdrift.toml")
484
+
485
+ if path.exists():
486
+ raise FileExistsError(f"Configuration file already exists: {path}")
487
+
488
+ path.write_text(EXAMPLE_CONFIG)
489
+ return path
@@ -0,0 +1,18 @@
1
+ {
2
+ "dotenvx_version": "1.51.4",
3
+ "download_urls": {
4
+ "darwin_amd64": "https://github.com/dotenvx/dotenvx/releases/download/v{version}/dotenvx-{version}-darwin-amd64.tar.gz",
5
+ "darwin_arm64": "https://github.com/dotenvx/dotenvx/releases/download/v{version}/dotenvx-{version}-darwin-arm64.tar.gz",
6
+ "linux_amd64": "https://github.com/dotenvx/dotenvx/releases/download/v{version}/dotenvx-{version}-linux-amd64.tar.gz",
7
+ "linux_arm64": "https://github.com/dotenvx/dotenvx/releases/download/v{version}/dotenvx-{version}-linux-arm64.tar.gz",
8
+ "windows_amd64": "https://github.com/dotenvx/dotenvx/releases/download/v{version}/dotenvx-{version}-windows-amd64.zip"
9
+ },
10
+ "sops_version": "3.11.0",
11
+ "sops_download_urls": {
12
+ "darwin_amd64": "https://github.com/getsops/sops/releases/download/v{version}/sops-v{version}.darwin.amd64",
13
+ "darwin_arm64": "https://github.com/getsops/sops/releases/download/v{version}/sops-v{version}.darwin.arm64",
14
+ "linux_amd64": "https://github.com/getsops/sops/releases/download/v{version}/sops-v{version}.linux.amd64",
15
+ "linux_arm64": "https://github.com/getsops/sops/releases/download/v{version}/sops-v{version}.linux.arm64",
16
+ "windows_amd64": "https://github.com/getsops/sops/releases/download/v{version}/sops-v{version}.amd64.exe"
17
+ }
18
+ }
@@ -0,0 +1,30 @@
1
+ """Core modules for envdrift."""
2
+
3
+ from envdrift.core.diff import DiffEngine, DiffResult, DiffType, VarDiff
4
+ from envdrift.core.encryption import EncryptionDetector, EncryptionReport
5
+ from envdrift.core.parser import EncryptionStatus, EnvFile, EnvParser, EnvVar
6
+ from envdrift.core.schema import FieldMetadata, SchemaLoader, SchemaMetadata
7
+ from envdrift.core.validator import ValidationResult, Validator
8
+
9
+ __all__ = [
10
+ # Parser
11
+ "EnvFile",
12
+ "EnvParser",
13
+ "EnvVar",
14
+ "EncryptionStatus",
15
+ # Schema
16
+ "FieldMetadata",
17
+ "SchemaLoader",
18
+ "SchemaMetadata",
19
+ # Validator
20
+ "ValidationResult",
21
+ "Validator",
22
+ # Diff
23
+ "DiffEngine",
24
+ "DiffResult",
25
+ "DiffType",
26
+ "VarDiff",
27
+ # Encryption
28
+ "EncryptionDetector",
29
+ "EncryptionReport",
30
+ ]