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,260 @@
1
+ """ENV file parser with multi-backend encryption detection.
2
+
3
+ Supports:
4
+ - dotenvx: Values starting with "encrypted:"
5
+ - SOPS: Values starting with "ENC[AES256_GCM,"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from pathlib import Path
14
+
15
+
16
+ class EncryptionStatus(Enum):
17
+ """Encryption status of an environment variable."""
18
+
19
+ ENCRYPTED = "encrypted" # Encrypted value (dotenvx or SOPS)
20
+ PLAINTEXT = "plaintext" # Unencrypted value
21
+ EMPTY = "empty" # No value (KEY= or KEY="")
22
+
23
+
24
+ @dataclass
25
+ class EnvVar:
26
+ """Parsed environment variable."""
27
+
28
+ name: str
29
+ value: str
30
+ line_number: int
31
+ encryption_status: EncryptionStatus
32
+ raw_line: str
33
+ encryption_backend: str | None = None # "dotenvx", "sops", or None
34
+
35
+ @property
36
+ def is_encrypted(self) -> bool:
37
+ """
38
+ Determine whether this environment variable's value is encrypted.
39
+
40
+ Returns:
41
+ True if the variable is encrypted, False otherwise.
42
+ """
43
+ return self.encryption_status == EncryptionStatus.ENCRYPTED
44
+
45
+ @property
46
+ def is_empty(self) -> bool:
47
+ """
48
+ Indicates whether the variable's value is empty.
49
+
50
+ Returns:
51
+ True if the variable's value is empty, False otherwise.
52
+ """
53
+ return self.encryption_status == EncryptionStatus.EMPTY
54
+
55
+
56
+ @dataclass
57
+ class EnvFile:
58
+ """Parsed .env file."""
59
+
60
+ path: Path
61
+ variables: dict[str, EnvVar] = field(default_factory=dict)
62
+ comments: list[str] = field(default_factory=list)
63
+
64
+ @property
65
+ def is_encrypted(self) -> bool:
66
+ """
67
+ Determine whether the file contains at least one encrypted environment variable.
68
+
69
+ Returns:
70
+ `true` if at least one variable in the file is encrypted, `false` otherwise.
71
+ """
72
+ return any(var.is_encrypted for var in self.variables.values())
73
+
74
+ @property
75
+ def is_fully_encrypted(self) -> bool:
76
+ """
77
+ Determine whether every non-empty environment variable in the file is encrypted.
78
+
79
+ Returns:
80
+ `true` if all non-empty variables have encryption status `ENCRYPTED`, `false` otherwise (also `false` when there are no non-empty variables).
81
+ """
82
+ non_empty_vars = [v for v in self.variables.values() if not v.is_empty]
83
+ if not non_empty_vars:
84
+ return False
85
+ return all(var.is_encrypted for var in non_empty_vars)
86
+
87
+ def get(self, name: str) -> EnvVar | None:
88
+ """
89
+ Retrieve the environment variable with the specified name from this EnvFile.
90
+
91
+ Parameters:
92
+ name (str): The variable name to look up.
93
+
94
+ Returns:
95
+ EnvVar | None: The matching EnvVar if found, `None` otherwise.
96
+ """
97
+ return self.variables.get(name)
98
+
99
+ def __contains__(self, name: str) -> bool:
100
+ """
101
+ Determine whether the EnvFile contains a variable with the given name.
102
+
103
+ Returns:
104
+ True if a variable with the given name exists in the file, False otherwise.
105
+ """
106
+ return name in self.variables
107
+
108
+ def __len__(self) -> int:
109
+ """
110
+ Number of environment variables contained in the EnvFile.
111
+
112
+ Returns:
113
+ int: The count of parsed variables.
114
+ """
115
+ return len(self.variables)
116
+
117
+
118
+ class EnvParser:
119
+ """Parse .env files with multi-backend encryption awareness.
120
+
121
+ Handles:
122
+ - Standard KEY=value
123
+ - Quoted values: KEY="value" or KEY='value'
124
+ - dotenvx encrypted: KEY="encrypted:xxxx"
125
+ - SOPS encrypted: KEY="ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]"
126
+ - Comments and blank lines (skipped)
127
+
128
+ Note:
129
+ Multiline values are not currently supported. Each line is parsed
130
+ independently. For multiline secrets (e.g., PEM keys), consider
131
+ base64 encoding or using a single-line escaped format.
132
+ """
133
+
134
+ # dotenvx encrypted value pattern
135
+ DOTENVX_ENCRYPTED_PATTERN = re.compile(r"^encrypted:")
136
+
137
+ # SOPS encrypted value pattern
138
+ SOPS_ENCRYPTED_PATTERN = re.compile(r"^ENC\[AES256_GCM,")
139
+
140
+ # Combined pattern for backward compatibility
141
+ ENCRYPTED_PATTERN = re.compile(r"^(encrypted:|ENC\[AES256_GCM,)")
142
+
143
+ # Pattern to match KEY=value lines
144
+ LINE_PATTERN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
145
+
146
+ def parse(self, path: Path | str) -> EnvFile:
147
+ """
148
+ Parse a .env file and produce an EnvFile representing its parsed contents.
149
+
150
+ Parameters:
151
+ path (Path | str): Filesystem path to the .env file.
152
+
153
+ Returns:
154
+ EnvFile: Parsed file containing variables and comments.
155
+
156
+ Raises:
157
+ FileNotFoundError: If the file does not exist.
158
+ """
159
+ path = Path(path)
160
+
161
+ if not path.exists():
162
+ raise FileNotFoundError(f"ENV file not found: {path}")
163
+
164
+ content = path.read_text(encoding="utf-8")
165
+ env_file = self.parse_string(content)
166
+ env_file.path = path
167
+
168
+ return env_file
169
+
170
+ def parse_string(self, content: str) -> EnvFile:
171
+ """
172
+ Parse .env formatted text, extracting variables (with detected encryption status) and comments.
173
+
174
+ Parameters:
175
+ content (str): The complete text content of a .env file to parse.
176
+
177
+ Returns:
178
+ EnvFile: An EnvFile populated with parsed EnvVar entries keyed by variable name and a list of comment lines.
179
+ """
180
+ env_file = EnvFile(path=Path())
181
+ lines = content.splitlines()
182
+
183
+ for line_num, line in enumerate(lines, start=1):
184
+ original_line = line
185
+ line = line.strip()
186
+
187
+ # Skip empty lines
188
+ if not line:
189
+ continue
190
+
191
+ # Collect comments
192
+ if line.startswith("#"):
193
+ env_file.comments.append(line)
194
+ continue
195
+
196
+ # Parse KEY=value
197
+ match = self.LINE_PATTERN.match(line)
198
+ if not match:
199
+ continue
200
+
201
+ key = match.group(1)
202
+ value = match.group(2).strip()
203
+
204
+ # Remove surrounding quotes
205
+ value = self._unquote(value)
206
+
207
+ # Determine encryption status and backend
208
+ encryption_status, encryption_backend = self._detect_encryption_status(value)
209
+
210
+ env_var = EnvVar(
211
+ name=key,
212
+ value=value,
213
+ line_number=line_num,
214
+ encryption_status=encryption_status,
215
+ raw_line=original_line,
216
+ encryption_backend=encryption_backend,
217
+ )
218
+
219
+ env_file.variables[key] = env_var
220
+
221
+ return env_file
222
+
223
+ def _unquote(self, value: str) -> str:
224
+ """
225
+ Remove a single matching pair of surrounding single or double quotes from the value.
226
+
227
+ Returns:
228
+ the unquoted string if the value is enclosed in matching single quotes ('...') or double quotes ("..."); otherwise the original value
229
+ """
230
+ if len(value) >= 2:
231
+ if (value.startswith('"') and value.endswith('"')) or (
232
+ value.startswith("'") and value.endswith("'")
233
+ ):
234
+ return value[1:-1]
235
+ return value
236
+
237
+ def _detect_encryption_status(self, value: str) -> tuple[EncryptionStatus, str | None]:
238
+ """
239
+ Detects the encryption status and backend of an environment variable value.
240
+
241
+ Parameters:
242
+ value (str): The unquoted value string to classify.
243
+
244
+ Returns:
245
+ tuple[EncryptionStatus, str | None]: A tuple of (status, backend) where:
246
+ - status is EncryptionStatus.EMPTY, ENCRYPTED, or PLAINTEXT
247
+ - backend is "dotenvx", "sops", or None
248
+ """
249
+ if not value:
250
+ return EncryptionStatus.EMPTY, None
251
+
252
+ # Check for dotenvx encrypted format
253
+ if self.DOTENVX_ENCRYPTED_PATTERN.match(value):
254
+ return EncryptionStatus.ENCRYPTED, "dotenvx"
255
+
256
+ # Check for SOPS encrypted format
257
+ if self.SOPS_ENCRYPTED_PATTERN.match(value):
258
+ return EncryptionStatus.ENCRYPTED, "sops"
259
+
260
+ return EncryptionStatus.PLAINTEXT, None
@@ -0,0 +1,239 @@
1
+ """Partial encryption functionality for envdrift.
2
+
3
+ This module implements a simple build pattern for partial encryption:
4
+ - Source files: .env.{env}.clear + .env.{env}.secret
5
+ - Generated file: .env.{env} (combined with warning header)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from envdrift.config import PartialEncryptionEnvironmentConfig
13
+ from envdrift.integrations.dotenvx import DotenvxError, DotenvxWrapper
14
+
15
+ WARNING_HEADER = """#/---------------------------------------------------/
16
+ #/ WARNING: AUTO-GENERATED FILE /
17
+ #/ DO NOT EDIT THIS FILE DIRECTLY /
18
+ #/ /
19
+ #/ This file is generated by: envdrift push /
20
+ #/ /
21
+ #/ To make changes: /
22
+ #/ 1. Edit: {clear_file:<36}/
23
+ #/ 2. Edit: {secret_file:<36}/
24
+ #/ 3. Run: envdrift pull-partial (to decrypt) /
25
+ #/ 4. Run: envdrift push (to regenerate this) /
26
+ #/---------------------------------------------------/
27
+ """
28
+
29
+
30
+ class PartialEncryptionError(Exception):
31
+ """Partial encryption operation failed."""
32
+
33
+ @classmethod
34
+ def encrypt_failed(cls, path: Path, cause: Exception) -> PartialEncryptionError:
35
+ """Create error for encryption failure."""
36
+ return cls(f"Failed to encrypt {path}: {cause}")
37
+
38
+ @classmethod
39
+ def decrypt_failed(cls, path: Path, cause: Exception) -> PartialEncryptionError:
40
+ """Create error for decryption failure."""
41
+ return cls(f"Failed to decrypt {path}: {cause}")
42
+
43
+ @classmethod
44
+ def file_not_found(cls, path: Path) -> PartialEncryptionError:
45
+ """Create error for missing file."""
46
+ return cls(f"Secret file not found: {path}")
47
+
48
+
49
+ def is_file_encrypted(file_path: Path) -> bool:
50
+ """
51
+ Check if a file contains dotenvx encrypted content.
52
+
53
+ Args:
54
+ file_path: Path to check
55
+
56
+ Returns:
57
+ True if file contains encrypted: prefix or DOTENV_VAULT markers
58
+ """
59
+ if not file_path.exists():
60
+ return False
61
+
62
+ content = file_path.read_text()
63
+ return "encrypted:" in content or "DOTENV_VAULT" in content
64
+
65
+
66
+ def combine_files(env_config: PartialEncryptionEnvironmentConfig) -> dict[str, int]:
67
+ """
68
+ Combine clear and encrypted secret files into a single combined file.
69
+
70
+ Process:
71
+ 1. Read .env.{env}.clear (cleartext vars)
72
+ 2. Read .env.{env}.secret (encrypted vars)
73
+ 3. Combine with warning header → .env.{env}
74
+
75
+ Args:
76
+ env_config: Environment configuration
77
+
78
+ Returns:
79
+ Dict with counts: {"clear_lines": X, "secret_vars": Y}
80
+
81
+ Raises:
82
+ PartialEncryptionError: If operation fails
83
+ """
84
+ clear_file = Path(env_config.clear_file)
85
+ secret_file = Path(env_config.secret_file)
86
+ combined_file = Path(env_config.combined_file)
87
+
88
+ # Read files
89
+ clear_lines = []
90
+ if clear_file.exists():
91
+ clear_lines = clear_file.read_text().splitlines()
92
+
93
+ secret_lines = []
94
+ if secret_file.exists():
95
+ secret_lines = secret_file.read_text().splitlines()
96
+
97
+ # Build combined content with warning header
98
+ warning = WARNING_HEADER.format(
99
+ clear_file=env_config.clear_file,
100
+ secret_file=env_config.secret_file,
101
+ )
102
+
103
+ combined_lines = warning.splitlines()
104
+ combined_lines.append("")
105
+
106
+ # Add clear section
107
+ if clear_lines:
108
+ combined_lines.append(f"# From {env_config.clear_file}")
109
+ combined_lines.extend(clear_lines)
110
+ combined_lines.append("")
111
+
112
+ # Add encrypted secret section
113
+ if secret_lines:
114
+ # Skip dotenvx header comments from secret file to avoid clutter
115
+ secret_content = [line for line in secret_lines if not line.strip().startswith("#/---")]
116
+ combined_lines.append(f"# From {env_config.secret_file} (encrypted)")
117
+ combined_lines.extend(secret_content)
118
+
119
+ # Write combined file
120
+ combined_file.write_text("\n".join(combined_lines) + "\n")
121
+
122
+ # Count variables in secret file
123
+ secret_var_count = sum(
124
+ 1 for line in secret_lines if "=" in line and not line.strip().startswith("#")
125
+ )
126
+
127
+ return {
128
+ "clear_lines": len(clear_lines),
129
+ "secret_vars": secret_var_count,
130
+ }
131
+
132
+
133
+ def encrypt_secret_file(env_config: PartialEncryptionEnvironmentConfig) -> None:
134
+ """
135
+ Encrypt the secret file in-place using dotenvx.
136
+
137
+ Args:
138
+ env_config: Environment configuration
139
+
140
+ Raises:
141
+ PartialEncryptionError: If encryption fails
142
+ """
143
+ secret_file = Path(env_config.secret_file)
144
+
145
+ if not secret_file.exists():
146
+ return
147
+
148
+ # Check if already encrypted
149
+ if is_file_encrypted(secret_file):
150
+ return
151
+
152
+ # Encrypt using dotenvx
153
+ dotenvx = DotenvxWrapper()
154
+ try:
155
+ dotenvx.encrypt(secret_file)
156
+ except DotenvxError as e:
157
+ raise PartialEncryptionError.encrypt_failed(secret_file, e) from e
158
+
159
+
160
+ def decrypt_secret_file(env_config: PartialEncryptionEnvironmentConfig) -> None:
161
+ """
162
+ Decrypt the secret file in-place using dotenvx.
163
+
164
+ Args:
165
+ env_config: Environment configuration
166
+
167
+ Raises:
168
+ PartialEncryptionError: If decryption fails
169
+ """
170
+ secret_file = Path(env_config.secret_file)
171
+
172
+ if not secret_file.exists():
173
+ raise PartialEncryptionError.file_not_found(secret_file)
174
+
175
+ # Check if already decrypted
176
+ if not is_file_encrypted(secret_file):
177
+ return # Already decrypted
178
+
179
+ # Decrypt using dotenvx
180
+ dotenvx = DotenvxWrapper()
181
+ try:
182
+ dotenvx.decrypt(secret_file)
183
+ except DotenvxError as e:
184
+ raise PartialEncryptionError.decrypt_failed(secret_file, e) from e
185
+
186
+
187
+ def push_partial_encryption(env_config: PartialEncryptionEnvironmentConfig) -> dict[str, int]:
188
+ """
189
+ Push operation: Encrypt secret file + combine with clear file.
190
+
191
+ Steps:
192
+ 1. Encrypt .env.{env}.secret using dotenvx (in-place)
193
+ 2. Combine .env.{env}.clear + encrypted .secret → .env.{env}
194
+
195
+ Args:
196
+ env_config: Environment configuration
197
+
198
+ Returns:
199
+ Dict with stats: {"clear_lines": X, "secret_vars": Y}
200
+
201
+ Raises:
202
+ PartialEncryptionError: If push operation fails
203
+ """
204
+ # Step 1: Encrypt secret file
205
+ encrypt_secret_file(env_config)
206
+
207
+ # Step 2: Combine files
208
+ stats = combine_files(env_config)
209
+
210
+ return stats
211
+
212
+
213
+ def pull_partial_encryption(env_config: PartialEncryptionEnvironmentConfig) -> bool:
214
+ """
215
+ Pull operation: Decrypt secret file for editing.
216
+
217
+ Steps:
218
+ 1. Decrypt .env.{env}.secret in-place
219
+
220
+ Args:
221
+ env_config: Environment configuration
222
+
223
+ Returns:
224
+ True if file was decrypted, False if already decrypted
225
+
226
+ Raises:
227
+ PartialEncryptionError: If pull operation fails
228
+ """
229
+ secret_file = Path(env_config.secret_file)
230
+
231
+ if not secret_file.exists():
232
+ raise PartialEncryptionError.file_not_found(secret_file)
233
+
234
+ was_encrypted = is_file_encrypted(secret_file)
235
+
236
+ # Decrypt secret file
237
+ decrypt_secret_file(env_config)
238
+
239
+ return was_encrypted