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,217 @@
1
+ """Abstract base class for encryption backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+
10
+
11
+ class EncryptionBackendError(Exception):
12
+ """Base exception for encryption backend operations."""
13
+
14
+ pass
15
+
16
+
17
+ class EncryptionNotFoundError(EncryptionBackendError):
18
+ """Encryption tool binary not found."""
19
+
20
+ pass
21
+
22
+
23
+ class EncryptionStatus(Enum):
24
+ """Encryption status of an environment variable value."""
25
+
26
+ ENCRYPTED = "encrypted"
27
+ PLAINTEXT = "plaintext"
28
+ EMPTY = "empty"
29
+
30
+
31
+ @dataclass
32
+ class EncryptionResult:
33
+ """Result of an encryption/decryption operation."""
34
+
35
+ success: bool
36
+ message: str
37
+ file_path: Path | None = None
38
+
39
+
40
+ class EncryptionBackend(ABC):
41
+ """Abstract interface for encryption backends.
42
+
43
+ Implementations must provide:
44
+ - encrypt: Encrypt a file
45
+ - decrypt: Decrypt a file
46
+ - is_installed: Check if the encryption tool is available
47
+ - detect_encryption_status: Detect if a value is encrypted by this backend
48
+ - has_encrypted_header: Check if file content has encryption markers
49
+ """
50
+
51
+ @property
52
+ @abstractmethod
53
+ def name(self) -> str:
54
+ """
55
+ Human-readable name of the encryption backend.
56
+
57
+ Returns:
58
+ str: Backend name (e.g., "dotenvx", "sops").
59
+ """
60
+ ...
61
+
62
+ @property
63
+ @abstractmethod
64
+ def encrypted_value_prefix(self) -> str | None:
65
+ """
66
+ Prefix used to identify encrypted values in .env files.
67
+
68
+ Returns:
69
+ str | None: The prefix (e.g., "encrypted:" for dotenvx, "ENC[" for SOPS),
70
+ or None if the backend doesn't use value prefixes.
71
+ """
72
+ ...
73
+
74
+ @abstractmethod
75
+ def is_installed(self) -> bool:
76
+ """
77
+ Determine whether the encryption tool binary is available.
78
+
79
+ Returns:
80
+ bool: True if the tool is installed and available, False otherwise.
81
+ """
82
+ ...
83
+
84
+ @abstractmethod
85
+ def get_version(self) -> str | None:
86
+ """
87
+ Get the installed version of the encryption tool.
88
+
89
+ Returns:
90
+ str | None: Version string if available, None if not installed.
91
+ """
92
+ ...
93
+
94
+ @abstractmethod
95
+ def encrypt(
96
+ self,
97
+ env_file: Path | str,
98
+ keys_file: Path | str | None = None,
99
+ **kwargs,
100
+ ) -> EncryptionResult:
101
+ """
102
+ Encrypt a .env file.
103
+
104
+ Parameters:
105
+ env_file (Path | str): Path to the .env file to encrypt.
106
+ keys_file (Path | str | None): Optional path to keys file.
107
+ **kwargs: Backend-specific options.
108
+
109
+ Returns:
110
+ EncryptionResult: Result containing success status and message.
111
+
112
+ Raises:
113
+ EncryptionBackendError: If encryption fails.
114
+ EncryptionNotFoundError: If the encryption tool is not installed.
115
+ """
116
+ ...
117
+
118
+ @abstractmethod
119
+ def decrypt(
120
+ self,
121
+ env_file: Path | str,
122
+ keys_file: Path | str | None = None,
123
+ **kwargs,
124
+ ) -> EncryptionResult:
125
+ """
126
+ Decrypt a .env file.
127
+
128
+ Parameters:
129
+ env_file (Path | str): Path to the .env file to decrypt.
130
+ keys_file (Path | str | None): Optional path to keys file.
131
+ **kwargs: Backend-specific options.
132
+
133
+ Returns:
134
+ EncryptionResult: Result containing success status and message.
135
+
136
+ Raises:
137
+ EncryptionBackendError: If decryption fails.
138
+ EncryptionNotFoundError: If the encryption tool is not installed.
139
+ """
140
+ ...
141
+
142
+ @abstractmethod
143
+ def detect_encryption_status(self, value: str) -> EncryptionStatus:
144
+ """
145
+ Detect the encryption status of a single value.
146
+
147
+ Parameters:
148
+ value (str): The unquoted value string to classify.
149
+
150
+ Returns:
151
+ EncryptionStatus: EMPTY if value is empty, ENCRYPTED if it matches
152
+ this backend's encrypted pattern, PLAINTEXT otherwise.
153
+ """
154
+ ...
155
+
156
+ @abstractmethod
157
+ def has_encrypted_header(self, content: str) -> bool:
158
+ """
159
+ Determine whether file content contains this backend's encryption markers.
160
+
161
+ Parameters:
162
+ content (str): Raw file content to inspect.
163
+
164
+ Returns:
165
+ bool: True if encryption markers are present, False otherwise.
166
+ """
167
+ ...
168
+
169
+ def is_file_encrypted(self, path: Path) -> bool:
170
+ """
171
+ Determine whether a file contains encryption markers.
172
+
173
+ Parameters:
174
+ path (Path): Filesystem path to the file to inspect.
175
+
176
+ Returns:
177
+ bool: True if the file contains encryption markers, False otherwise.
178
+ """
179
+ if not path.exists():
180
+ return False
181
+
182
+ content = path.read_text(encoding="utf-8")
183
+ return self.has_encrypted_header(content)
184
+
185
+ def is_value_encrypted(self, value: str) -> bool:
186
+ """
187
+ Check if a value is encrypted by this backend.
188
+
189
+ Parameters:
190
+ value (str): The value to check.
191
+
192
+ Returns:
193
+ bool: True if the value is encrypted, False otherwise.
194
+ """
195
+ return self.detect_encryption_status(value) == EncryptionStatus.ENCRYPTED
196
+
197
+ @abstractmethod
198
+ def install_instructions(self) -> str:
199
+ """
200
+ Provide installation instructions for the encryption tool.
201
+
202
+ Returns:
203
+ str: Human-readable installation instructions.
204
+ """
205
+ ...
206
+
207
+ def ensure_installed(self) -> None:
208
+ """
209
+ Ensure the encryption tool is installed, raising if not.
210
+
211
+ Raises:
212
+ EncryptionNotFoundError: If the tool is not installed.
213
+ """
214
+ if not self.is_installed():
215
+ raise EncryptionNotFoundError(
216
+ f"{self.name} is not installed.\n{self.install_instructions()}"
217
+ )
@@ -0,0 +1,236 @@
1
+ """Dotenvx encryption backend implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import ClassVar
8
+
9
+ from envdrift.encryption.base import (
10
+ EncryptionBackend,
11
+ EncryptionBackendError,
12
+ EncryptionNotFoundError,
13
+ EncryptionResult,
14
+ EncryptionStatus,
15
+ )
16
+
17
+
18
+ class DotenvxEncryptionBackend(EncryptionBackend):
19
+ """Encryption backend using dotenvx CLI.
20
+
21
+ dotenvx is a tool for encrypting .env files with a public/private key pair.
22
+ It stores encrypted values with the prefix "encrypted:" and adds file headers.
23
+ """
24
+
25
+ # Patterns that indicate encrypted values (dotenvx format)
26
+ ENCRYPTED_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^encrypted:")
27
+
28
+ # Header patterns that indicate the file has been encrypted by dotenvx
29
+ ENCRYPTED_FILE_MARKERS: ClassVar[list[str]] = [
30
+ "#/---BEGIN DOTENV ENCRYPTED---/",
31
+ "DOTENV_PUBLIC_KEY",
32
+ ]
33
+
34
+ def __init__(self, auto_install: bool = False):
35
+ """
36
+ Initialize the dotenvx encryption backend.
37
+
38
+ Parameters:
39
+ auto_install (bool): If True, attempt to auto-install dotenvx if not found.
40
+ """
41
+ self._auto_install = auto_install
42
+ self._wrapper = None
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ """Return backend name."""
47
+ return "dotenvx"
48
+
49
+ @property
50
+ def encrypted_value_prefix(self) -> str:
51
+ """Return the prefix used to identify encrypted values."""
52
+ return "encrypted:"
53
+
54
+ def _get_wrapper(self):
55
+ """Lazily initialize the DotenvxWrapper."""
56
+ if self._wrapper is None:
57
+ from envdrift.integrations.dotenvx import DotenvxWrapper
58
+
59
+ self._wrapper = DotenvxWrapper(auto_install=self._auto_install)
60
+ return self._wrapper
61
+
62
+ def is_installed(self) -> bool:
63
+ """Check if dotenvx is installed."""
64
+ from envdrift.integrations.dotenvx import DotenvxError, DotenvxNotFoundError
65
+
66
+ try:
67
+ return self._get_wrapper().is_installed()
68
+ except (DotenvxNotFoundError, DotenvxError, OSError, RuntimeError):
69
+ return False
70
+
71
+ def get_version(self) -> str | None:
72
+ """Get the installed dotenvx version."""
73
+ from envdrift.integrations.dotenvx import DotenvxError, DotenvxNotFoundError
74
+
75
+ try:
76
+ if not self.is_installed():
77
+ return None
78
+ return self._get_wrapper().get_version()
79
+ except (DotenvxNotFoundError, DotenvxError, OSError, RuntimeError):
80
+ return None
81
+
82
+ def encrypt(
83
+ self,
84
+ env_file: Path | str,
85
+ keys_file: Path | str | None = None,
86
+ **kwargs,
87
+ ) -> EncryptionResult:
88
+ """
89
+ Encrypt a .env file using dotenvx.
90
+
91
+ Parameters:
92
+ env_file (Path | str): Path to the .env file to encrypt.
93
+ keys_file (Path | str | None): Optional path to .env.keys file.
94
+ **kwargs: Additional options:
95
+ - env (dict): Environment variables to pass to subprocess.
96
+ - cwd (Path | str): Working directory for subprocess.
97
+
98
+ Returns:
99
+ EncryptionResult: Result of the encryption operation.
100
+ """
101
+ env_file = Path(env_file)
102
+
103
+ if not env_file.exists():
104
+ return EncryptionResult(
105
+ success=False,
106
+ message=f"File not found: {env_file}",
107
+ file_path=env_file,
108
+ )
109
+
110
+ if not self.is_installed():
111
+ raise EncryptionNotFoundError(
112
+ f"dotenvx is not installed.\n{self.install_instructions()}"
113
+ )
114
+
115
+ try:
116
+ from envdrift.integrations.dotenvx import DotenvxError
117
+
118
+ wrapper = self._get_wrapper()
119
+ wrapper.encrypt(
120
+ env_file=env_file,
121
+ env_keys_file=keys_file,
122
+ env=kwargs.get("env"),
123
+ cwd=kwargs.get("cwd"),
124
+ )
125
+ return EncryptionResult(
126
+ success=True,
127
+ message=f"Encrypted {env_file}",
128
+ file_path=env_file,
129
+ )
130
+ except DotenvxError as e:
131
+ raise EncryptionBackendError(f"dotenvx encryption failed: {e}") from e
132
+
133
+ def decrypt(
134
+ self,
135
+ env_file: Path | str,
136
+ keys_file: Path | str | None = None,
137
+ **kwargs,
138
+ ) -> EncryptionResult:
139
+ """
140
+ Decrypt a .env file using dotenvx.
141
+
142
+ Parameters:
143
+ env_file (Path | str): Path to the .env file to decrypt.
144
+ keys_file (Path | str | None): Optional path to .env.keys file.
145
+ **kwargs: Additional options:
146
+ - env (dict): Environment variables to pass to subprocess.
147
+ - cwd (Path | str): Working directory for subprocess.
148
+
149
+ Returns:
150
+ EncryptionResult: Result of the decryption operation.
151
+ """
152
+ env_file = Path(env_file)
153
+
154
+ if not env_file.exists():
155
+ return EncryptionResult(
156
+ success=False,
157
+ message=f"File not found: {env_file}",
158
+ file_path=env_file,
159
+ )
160
+
161
+ if not self.is_installed():
162
+ raise EncryptionNotFoundError(
163
+ f"dotenvx is not installed.\n{self.install_instructions()}"
164
+ )
165
+
166
+ try:
167
+ from envdrift.integrations.dotenvx import DotenvxError
168
+
169
+ wrapper = self._get_wrapper()
170
+ wrapper.decrypt(
171
+ env_file=env_file,
172
+ env_keys_file=keys_file,
173
+ env=kwargs.get("env"),
174
+ cwd=kwargs.get("cwd"),
175
+ )
176
+ return EncryptionResult(
177
+ success=True,
178
+ message=f"Decrypted {env_file}",
179
+ file_path=env_file,
180
+ )
181
+ except DotenvxError as e:
182
+ raise EncryptionBackendError(f"dotenvx decryption failed: {e}") from e
183
+
184
+ def detect_encryption_status(self, value: str) -> EncryptionStatus:
185
+ """
186
+ Detect the encryption status of a value.
187
+
188
+ Parameters:
189
+ value (str): The unquoted value string to classify.
190
+
191
+ Returns:
192
+ EncryptionStatus: EMPTY if value is empty, ENCRYPTED if it starts
193
+ with "encrypted:", PLAINTEXT otherwise.
194
+ """
195
+ if not value:
196
+ return EncryptionStatus.EMPTY
197
+
198
+ if self.ENCRYPTED_PATTERN.match(value):
199
+ return EncryptionStatus.ENCRYPTED
200
+
201
+ return EncryptionStatus.PLAINTEXT
202
+
203
+ def has_encrypted_header(self, content: str) -> bool:
204
+ """
205
+ Check if file content contains dotenvx encryption markers.
206
+
207
+ Parameters:
208
+ content (str): Raw file content to inspect.
209
+
210
+ Returns:
211
+ bool: True if dotenvx encryption markers are present.
212
+ """
213
+ for marker in self.ENCRYPTED_FILE_MARKERS:
214
+ if marker in content:
215
+ return True
216
+ return False
217
+
218
+ def install_instructions(self) -> str:
219
+ """Return installation instructions for dotenvx."""
220
+ from envdrift.integrations.dotenvx import DOTENVX_VERSION
221
+
222
+ return f"""
223
+ dotenvx is not installed.
224
+
225
+ Option 1 - Install to ~/.local/bin (recommended):
226
+ curl -sfS "https://dotenvx.sh?directory=$HOME/.local/bin" | sh -s -- --version={DOTENVX_VERSION}
227
+ (Make sure ~/.local/bin is in your PATH)
228
+
229
+ Option 2 - Install to current directory:
230
+ curl -sfS "https://dotenvx.sh?directory=." | sh -s -- --version={DOTENVX_VERSION}
231
+
232
+ Option 3 - System-wide install (requires sudo):
233
+ curl -sfS https://dotenvx.sh | sudo sh -s -- --version={DOTENVX_VERSION}
234
+
235
+ After installing, run your envdrift command again.
236
+ """