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.
- envdrift/__init__.py +30 -0
- envdrift/_version.py +34 -0
- envdrift/api.py +192 -0
- envdrift/cli.py +42 -0
- envdrift/cli_commands/__init__.py +1 -0
- envdrift/cli_commands/diff.py +91 -0
- envdrift/cli_commands/encryption.py +630 -0
- envdrift/cli_commands/encryption_helpers.py +93 -0
- envdrift/cli_commands/hook.py +75 -0
- envdrift/cli_commands/init_cmd.py +117 -0
- envdrift/cli_commands/partial.py +222 -0
- envdrift/cli_commands/sync.py +1140 -0
- envdrift/cli_commands/validate.py +109 -0
- envdrift/cli_commands/vault.py +376 -0
- envdrift/cli_commands/version.py +15 -0
- envdrift/config.py +489 -0
- envdrift/constants.json +18 -0
- envdrift/core/__init__.py +30 -0
- envdrift/core/diff.py +233 -0
- envdrift/core/encryption.py +400 -0
- envdrift/core/parser.py +260 -0
- envdrift/core/partial_encryption.py +239 -0
- envdrift/core/schema.py +253 -0
- envdrift/core/validator.py +312 -0
- envdrift/encryption/__init__.py +117 -0
- envdrift/encryption/base.py +217 -0
- envdrift/encryption/dotenvx.py +236 -0
- envdrift/encryption/sops.py +458 -0
- envdrift/env_files.py +60 -0
- envdrift/integrations/__init__.py +21 -0
- envdrift/integrations/dotenvx.py +689 -0
- envdrift/integrations/precommit.py +266 -0
- envdrift/integrations/sops.py +85 -0
- envdrift/output/__init__.py +21 -0
- envdrift/output/rich.py +424 -0
- envdrift/py.typed +0 -0
- envdrift/sync/__init__.py +26 -0
- envdrift/sync/config.py +218 -0
- envdrift/sync/engine.py +383 -0
- envdrift/sync/operations.py +138 -0
- envdrift/sync/result.py +99 -0
- envdrift/vault/__init__.py +107 -0
- envdrift/vault/aws.py +282 -0
- envdrift/vault/azure.py +170 -0
- envdrift/vault/base.py +150 -0
- envdrift/vault/gcp.py +210 -0
- envdrift/vault/hashicorp.py +238 -0
- envdrift-4.2.1.dist-info/METADATA +160 -0
- envdrift-4.2.1.dist-info/RECORD +52 -0
- envdrift-4.2.1.dist-info/WHEEL +4 -0
- envdrift-4.2.1.dist-info/entry_points.txt +2 -0
- 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
|
+
"""
|