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,689 @@
|
|
|
1
|
+
"""dotenvx CLI wrapper with local binary installation.
|
|
2
|
+
|
|
3
|
+
This module wraps the dotenvx binary for encryption/decryption of .env files.
|
|
4
|
+
Key features:
|
|
5
|
+
- Installs dotenvx binary inside .venv/bin/ (NOT system-wide)
|
|
6
|
+
- Pins version from constants.json for reproducibility
|
|
7
|
+
- Cross-platform support (Windows, macOS, Linux)
|
|
8
|
+
- No Node.js dependency required
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess # nosec B404
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import urllib.request
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_constants() -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Load and return the parsed contents of the package's constants.json.
|
|
29
|
+
|
|
30
|
+
The file is resolved relative to this module (../constants.json).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
dict: Parsed JSON object from constants.json.
|
|
34
|
+
"""
|
|
35
|
+
constants_path = Path(__file__).parent.parent / "constants.json"
|
|
36
|
+
with open(constants_path) as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_dotenvx_version() -> str:
|
|
41
|
+
"""
|
|
42
|
+
Return the pinned dotenvx version from the package constants.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
version (str): The pinned dotenvx version string (for example, "1.2.3").
|
|
46
|
+
"""
|
|
47
|
+
return _load_constants()["dotenvx_version"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_download_url_templates() -> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Return the download URL templates loaded from constants.json.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
download_urls (dict[str, str]): Mapping from platform/architecture identifiers to URL templates that include a version placeholder.
|
|
56
|
+
"""
|
|
57
|
+
return _load_constants()["download_urls"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Load version from constants.json
|
|
61
|
+
DOTENVX_VERSION = _get_dotenvx_version()
|
|
62
|
+
|
|
63
|
+
# Download URLs by platform - loaded from constants.json and mapped to tuples
|
|
64
|
+
_URL_TEMPLATES = _get_download_url_templates()
|
|
65
|
+
DOWNLOAD_URLS = {
|
|
66
|
+
("Darwin", "x86_64"): _URL_TEMPLATES["darwin_amd64"],
|
|
67
|
+
("Darwin", "arm64"): _URL_TEMPLATES["darwin_arm64"],
|
|
68
|
+
("Linux", "x86_64"): _URL_TEMPLATES["linux_amd64"],
|
|
69
|
+
("Linux", "aarch64"): _URL_TEMPLATES["linux_arm64"],
|
|
70
|
+
("Windows", "AMD64"): _URL_TEMPLATES["windows_amd64"],
|
|
71
|
+
("Windows", "x86_64"): _URL_TEMPLATES["windows_amd64"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DotenvxNotFoundError(Exception):
|
|
76
|
+
"""dotenvx binary not found."""
|
|
77
|
+
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DotenvxError(Exception):
|
|
82
|
+
"""dotenvx command failed."""
|
|
83
|
+
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DotenvxInstallError(Exception):
|
|
88
|
+
"""Failed to install dotenvx."""
|
|
89
|
+
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_platform_info() -> tuple[str, str]:
|
|
94
|
+
"""
|
|
95
|
+
Return the current platform name and a normalized architecture identifier.
|
|
96
|
+
|
|
97
|
+
The returned architecture value normalizes common variants (for example, AMD64 -> `x86_64` on non-Windows systems; `arm64` vs `aarch64` differs between Darwin and other OSes).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
tuple: `(system, machine)` where `system` is the platform name (e.g., "Darwin", "Linux", "Windows") and `machine` is the normalized architecture (e.g., "x86_64", "arm64", "aarch64", "AMD64").
|
|
101
|
+
"""
|
|
102
|
+
system = platform.system()
|
|
103
|
+
machine = platform.machine()
|
|
104
|
+
|
|
105
|
+
# Normalize some architecture names
|
|
106
|
+
if machine == "x86_64":
|
|
107
|
+
pass # Keep as is
|
|
108
|
+
elif machine in ("AMD64", "amd64"):
|
|
109
|
+
machine = "AMD64" if system == "Windows" else "x86_64"
|
|
110
|
+
elif machine in ("arm64", "aarch64"):
|
|
111
|
+
machine = "arm64" if system == "Darwin" else "aarch64"
|
|
112
|
+
|
|
113
|
+
return system, machine
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_venv_bin_dir() -> Path:
|
|
117
|
+
"""
|
|
118
|
+
Determine the filesystem path to the current virtual environment's executable directory.
|
|
119
|
+
|
|
120
|
+
Searches these locations in order: the VIRTUAL_ENV environment variable, candidate venv directories found on sys.path (including uv tool and pipx installs), a .venv directory in the current working directory, and finally falls back to user bin directories (~/.local/bin on Linux/macOS or %APPDATA%\\Python\\Scripts on Windows). Returns the venv's "bin" subdirectory on POSIX systems or "Scripts" on Windows.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Path: Path to the virtual environment's bin directory (or Scripts on Windows).
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
RuntimeError: If no virtual environment directory can be located.
|
|
127
|
+
"""
|
|
128
|
+
# Check for virtual environment
|
|
129
|
+
venv_path = os.environ.get("VIRTUAL_ENV")
|
|
130
|
+
if venv_path:
|
|
131
|
+
venv = Path(venv_path)
|
|
132
|
+
if platform.system() == "Windows":
|
|
133
|
+
return venv / "Scripts"
|
|
134
|
+
return venv / "bin"
|
|
135
|
+
|
|
136
|
+
# Try to find venv relative to the package
|
|
137
|
+
# This handles cases where VIRTUAL_ENV isn't set
|
|
138
|
+
for path in sys.path:
|
|
139
|
+
p = Path(path)
|
|
140
|
+
# Check for standard venv directories
|
|
141
|
+
if ".venv" in p.parts or "venv" in p.parts:
|
|
142
|
+
# Walk up to find the venv root
|
|
143
|
+
while p.name not in (".venv", "venv") and p.parent != p:
|
|
144
|
+
p = p.parent
|
|
145
|
+
if p.name in (".venv", "venv"):
|
|
146
|
+
if platform.system() == "Windows":
|
|
147
|
+
return p / "Scripts"
|
|
148
|
+
return p / "bin"
|
|
149
|
+
# Check for uv tool install (e.g., ~/.local/share/uv/tools/envdrift/)
|
|
150
|
+
# or pipx install (e.g., ~/.local/pipx/venvs/envdrift/)
|
|
151
|
+
is_uv_tool = "uv" in p.parts and "tools" in p.parts
|
|
152
|
+
is_pipx = "pipx" in p.parts and "venvs" in p.parts
|
|
153
|
+
if is_uv_tool or is_pipx:
|
|
154
|
+
# Walk up to find the tool's venv root
|
|
155
|
+
# Linux: lib/pythonX.Y/site-packages (3 levels up)
|
|
156
|
+
# Windows: Lib/site-packages (2 levels up)
|
|
157
|
+
while p.name != "site-packages" and p.parent != p:
|
|
158
|
+
p = p.parent
|
|
159
|
+
if p.name == "site-packages":
|
|
160
|
+
# Check parent structure to determine levels
|
|
161
|
+
if platform.system() == "Windows":
|
|
162
|
+
# Windows: site-packages -> Lib -> tool_venv
|
|
163
|
+
tool_venv = p.parent.parent
|
|
164
|
+
bin_dir = tool_venv / "Scripts"
|
|
165
|
+
else:
|
|
166
|
+
# Linux: site-packages -> pythonX.Y -> lib -> tool_venv
|
|
167
|
+
tool_venv = p.parent.parent.parent
|
|
168
|
+
bin_dir = tool_venv / "bin"
|
|
169
|
+
# Validate path exists before returning
|
|
170
|
+
if bin_dir.parent.exists():
|
|
171
|
+
return bin_dir
|
|
172
|
+
|
|
173
|
+
# Default to creating in current directory's .venv
|
|
174
|
+
cwd_venv = Path.cwd() / ".venv"
|
|
175
|
+
if cwd_venv.exists():
|
|
176
|
+
if platform.system() == "Windows":
|
|
177
|
+
return cwd_venv / "Scripts"
|
|
178
|
+
return cwd_venv / "bin"
|
|
179
|
+
|
|
180
|
+
# Fallback for plain pip install (system or --user)
|
|
181
|
+
# Use user-writable bin directory
|
|
182
|
+
if platform.system() == "Windows":
|
|
183
|
+
# Windows user scripts: %APPDATA%\Python\Scripts
|
|
184
|
+
appdata = os.environ.get("APPDATA")
|
|
185
|
+
if appdata:
|
|
186
|
+
user_scripts = Path(appdata) / "Python" / "Scripts"
|
|
187
|
+
user_scripts.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
return user_scripts
|
|
189
|
+
else:
|
|
190
|
+
# Linux/macOS: ~/.local/bin (standard user bin directory)
|
|
191
|
+
user_bin = Path.home() / ".local" / "bin"
|
|
192
|
+
user_bin.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
return user_bin
|
|
194
|
+
|
|
195
|
+
# Only reachable on Windows when APPDATA is not set
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
"Cannot find virtual environment or user bin directory. "
|
|
198
|
+
"On Windows, ensure the APPDATA environment variable is set, "
|
|
199
|
+
"or activate a virtual environment with: python -m venv .venv"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_dotenvx_path() -> Path:
|
|
204
|
+
"""
|
|
205
|
+
Return the expected filesystem path of the dotenvx executable within the project's virtual environment.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Path to the dotenvx binary inside the virtual environment's bin (or Scripts on Windows).
|
|
209
|
+
"""
|
|
210
|
+
bin_dir = get_venv_bin_dir()
|
|
211
|
+
binary_name = "dotenvx.exe" if platform.system() == "Windows" else "dotenvx"
|
|
212
|
+
return bin_dir / binary_name
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DotenvxInstaller:
|
|
216
|
+
"""Install dotenvx binary to the virtual environment."""
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
version: str = DOTENVX_VERSION,
|
|
221
|
+
progress_callback: Callable[[str], None] | None = None,
|
|
222
|
+
):
|
|
223
|
+
"""Initialize installer.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
version: dotenvx version to install
|
|
227
|
+
progress_callback: Optional callback for progress updates
|
|
228
|
+
"""
|
|
229
|
+
self.version = version
|
|
230
|
+
self.progress = progress_callback or (lambda x: None)
|
|
231
|
+
|
|
232
|
+
def get_download_url(self) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Determine the platform-specific download URL for the configured dotenvx version.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
download_url (str): The concrete URL for the current system and architecture with the target version substituted.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
DotenvxInstallError: If the current platform/architecture is not supported.
|
|
241
|
+
"""
|
|
242
|
+
system, machine = get_platform_info()
|
|
243
|
+
key = (system, machine)
|
|
244
|
+
|
|
245
|
+
if key not in DOWNLOAD_URLS:
|
|
246
|
+
raise DotenvxInstallError(
|
|
247
|
+
f"Unsupported platform: {system} {machine}. "
|
|
248
|
+
f"Supported: {', '.join(f'{s}/{m}' for s, m in DOWNLOAD_URLS)}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Replace version in URL
|
|
252
|
+
url = DOWNLOAD_URLS[key]
|
|
253
|
+
if "{version}" in url:
|
|
254
|
+
return url.format(version=self.version)
|
|
255
|
+
return url.replace(DOTENVX_VERSION, self.version)
|
|
256
|
+
|
|
257
|
+
def download_and_extract(self, target_path: Path) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Download the packaged dotenvx release for the current platform and place the extracted binary at the given target path.
|
|
260
|
+
|
|
261
|
+
The function creates the target directory if necessary, extracts the platform-specific archive, copies the included dotenvx binary to target_path (overwriting if present), and sets executable permissions on non-Windows systems.
|
|
262
|
+
|
|
263
|
+
Parameters:
|
|
264
|
+
target_path (Path): Destination path for the dotenvx executable.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
DotenvxInstallError: If the download, extraction, or locating/copying of the binary fails.
|
|
268
|
+
"""
|
|
269
|
+
url = self.get_download_url()
|
|
270
|
+
self.progress(f"Downloading dotenvx v{self.version}...")
|
|
271
|
+
|
|
272
|
+
# Create temp directory
|
|
273
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
274
|
+
tmp_path = Path(tmp_dir)
|
|
275
|
+
archive_name = url.split("/")[-1]
|
|
276
|
+
archive_path = tmp_path / archive_name
|
|
277
|
+
|
|
278
|
+
# Download
|
|
279
|
+
try:
|
|
280
|
+
urllib.request.urlretrieve(url, archive_path) # nosec B310
|
|
281
|
+
except Exception as e:
|
|
282
|
+
raise DotenvxInstallError(f"Download failed: {e}") from e
|
|
283
|
+
|
|
284
|
+
self.progress("Extracting...")
|
|
285
|
+
|
|
286
|
+
# Extract based on archive type
|
|
287
|
+
if archive_name.endswith(".tar.gz"):
|
|
288
|
+
self._extract_tar_gz(archive_path, tmp_path)
|
|
289
|
+
elif archive_name.endswith(".zip"):
|
|
290
|
+
self._extract_zip(archive_path, tmp_path)
|
|
291
|
+
else:
|
|
292
|
+
raise DotenvxInstallError(f"Unknown archive format: {archive_name}")
|
|
293
|
+
|
|
294
|
+
# Find the binary
|
|
295
|
+
binary_name = "dotenvx.exe" if platform.system() == "Windows" else "dotenvx"
|
|
296
|
+
extracted_binary = None
|
|
297
|
+
|
|
298
|
+
for f in tmp_path.rglob(binary_name):
|
|
299
|
+
if f.is_file():
|
|
300
|
+
extracted_binary = f
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
if not extracted_binary:
|
|
304
|
+
raise DotenvxInstallError(f"Binary '{binary_name}' not found in archive")
|
|
305
|
+
|
|
306
|
+
# Ensure target directory exists
|
|
307
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
|
|
309
|
+
# Copy to target
|
|
310
|
+
shutil.copy2(extracted_binary, target_path)
|
|
311
|
+
|
|
312
|
+
# Make executable (Unix)
|
|
313
|
+
if platform.system() != "Windows":
|
|
314
|
+
target_path.chmod(
|
|
315
|
+
target_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
self.progress(f"Installed to {target_path}")
|
|
319
|
+
|
|
320
|
+
def _extract_tar_gz(self, archive_path: Path, target_dir: Path) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Extracts all files from a gzip-compressed tar archive into the given target directory.
|
|
323
|
+
|
|
324
|
+
Parameters:
|
|
325
|
+
archive_path (Path): Path to the .tar.gz archive to extract.
|
|
326
|
+
target_dir (Path): Destination directory where the archive contents will be extracted.
|
|
327
|
+
"""
|
|
328
|
+
import tarfile
|
|
329
|
+
|
|
330
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
331
|
+
# Filter to prevent path traversal attacks (CVE-2007-4559)
|
|
332
|
+
for member in tar.getmembers():
|
|
333
|
+
member_path = target_dir / member.name
|
|
334
|
+
# Resolve to absolute and ensure it's within target_dir
|
|
335
|
+
if not member_path.resolve().is_relative_to(target_dir.resolve()):
|
|
336
|
+
raise DotenvxInstallError(f"Unsafe path in archive: {member.name}")
|
|
337
|
+
try:
|
|
338
|
+
tar.extractall(target_dir, filter="data") # nosec B202
|
|
339
|
+
except TypeError:
|
|
340
|
+
# Python <3.12 doesn't support the filter argument.
|
|
341
|
+
tar.extractall(target_dir) # nosec B202
|
|
342
|
+
|
|
343
|
+
def _extract_zip(self, archive_path: Path, target_dir: Path) -> None:
|
|
344
|
+
"""
|
|
345
|
+
Extract the contents of a ZIP archive into the given target directory.
|
|
346
|
+
|
|
347
|
+
Parameters:
|
|
348
|
+
archive_path (Path): Path to the ZIP archive to extract.
|
|
349
|
+
target_dir (Path): Directory where archive contents will be extracted.
|
|
350
|
+
"""
|
|
351
|
+
import zipfile
|
|
352
|
+
|
|
353
|
+
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
|
354
|
+
# Filter to prevent path traversal attacks
|
|
355
|
+
for name in zip_ref.namelist():
|
|
356
|
+
member_path = target_dir / name
|
|
357
|
+
# Resolve to absolute and ensure it's within target_dir
|
|
358
|
+
if not member_path.resolve().is_relative_to(target_dir.resolve()):
|
|
359
|
+
raise DotenvxInstallError(f"Unsafe path in archive: {name}")
|
|
360
|
+
zip_ref.extractall(target_dir) # nosec B202
|
|
361
|
+
|
|
362
|
+
def install(self, force: bool = False) -> Path:
|
|
363
|
+
"""
|
|
364
|
+
Install the pinned dotenvx binary into the virtual environment.
|
|
365
|
+
|
|
366
|
+
If the target binary already exists and `force` is False, verifies the installed version and skips reinstallation when it matches the requested version; otherwise downloads and installs the requested version.
|
|
367
|
+
|
|
368
|
+
Parameters:
|
|
369
|
+
force (bool): Reinstall even if a binary already exists.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Path: Path to the installed dotenvx binary.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
DotenvxInstallError: If installation fails.
|
|
376
|
+
"""
|
|
377
|
+
target_path = get_dotenvx_path()
|
|
378
|
+
|
|
379
|
+
if target_path.exists() and not force:
|
|
380
|
+
# Verify version
|
|
381
|
+
try:
|
|
382
|
+
result = subprocess.run( # nosec B603
|
|
383
|
+
[str(target_path), "--version"],
|
|
384
|
+
capture_output=True,
|
|
385
|
+
text=True,
|
|
386
|
+
timeout=10,
|
|
387
|
+
)
|
|
388
|
+
if self.version in result.stdout:
|
|
389
|
+
self.progress(f"dotenvx v{self.version} already installed")
|
|
390
|
+
return target_path
|
|
391
|
+
except Exception as e: # nosec B110
|
|
392
|
+
# Version check failed, will reinstall
|
|
393
|
+
import logging
|
|
394
|
+
|
|
395
|
+
logging.debug(f"Version check failed: {e}")
|
|
396
|
+
|
|
397
|
+
self.download_and_extract(target_path)
|
|
398
|
+
return target_path
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def ensure_installed(version: str = DOTENVX_VERSION) -> Path:
|
|
402
|
+
"""
|
|
403
|
+
Ensure the dotenvx binary of the given version is installed into the virtual environment.
|
|
404
|
+
|
|
405
|
+
Parameters:
|
|
406
|
+
version (str): Target dotenvx version to install.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Path: Path to the installed dotenvx binary
|
|
410
|
+
"""
|
|
411
|
+
installer = DotenvxInstaller(version=version)
|
|
412
|
+
return installer.install()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class DotenvxWrapper:
|
|
416
|
+
"""Wrapper around dotenvx CLI.
|
|
417
|
+
|
|
418
|
+
This wrapper:
|
|
419
|
+
- Optionally installs dotenvx if not found (auto_install defaults to False)
|
|
420
|
+
- Uses the binary from .venv/bin/ (not system-wide)
|
|
421
|
+
- Provides Python-friendly interface to dotenvx commands
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
def __init__(self, auto_install: bool = False, version: str = DOTENVX_VERSION):
|
|
425
|
+
"""
|
|
426
|
+
Create a DotenvxWrapper that provides methods to run and manage the dotenvx CLI within a virtual environment.
|
|
427
|
+
|
|
428
|
+
Parameters:
|
|
429
|
+
auto_install (bool): If True, attempt to install dotenvx into the project's virtual environment when it cannot be found.
|
|
430
|
+
version (str): Pinned dotenvx version to use for lookups and installations.
|
|
431
|
+
"""
|
|
432
|
+
self.auto_install = auto_install
|
|
433
|
+
self.version = version
|
|
434
|
+
self._binary_path: Path | None = None
|
|
435
|
+
|
|
436
|
+
def _find_binary(self) -> Path:
|
|
437
|
+
"""
|
|
438
|
+
Locate and return the filesystem path to the dotenvx executable, caching the result.
|
|
439
|
+
|
|
440
|
+
Searches the virtual environment, then the system PATH, and attempts to auto-install the binary when configured to do so.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Path: Filesystem path to the found dotenvx executable.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
DotenvxNotFoundError: If the executable cannot be found and auto-installation is not enabled or fails.
|
|
447
|
+
"""
|
|
448
|
+
if self._binary_path and self._binary_path.exists():
|
|
449
|
+
return self._binary_path
|
|
450
|
+
|
|
451
|
+
# Check in venv first
|
|
452
|
+
try:
|
|
453
|
+
venv_path = get_dotenvx_path()
|
|
454
|
+
if venv_path.exists():
|
|
455
|
+
self._binary_path = venv_path
|
|
456
|
+
return venv_path
|
|
457
|
+
except RuntimeError:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
# Check system PATH
|
|
461
|
+
system_path = shutil.which("dotenvx")
|
|
462
|
+
if system_path:
|
|
463
|
+
self._binary_path = Path(system_path)
|
|
464
|
+
return self._binary_path
|
|
465
|
+
|
|
466
|
+
# Auto-install if enabled
|
|
467
|
+
if self.auto_install:
|
|
468
|
+
try:
|
|
469
|
+
installer = DotenvxInstaller(version=self.version)
|
|
470
|
+
self._binary_path = installer.install()
|
|
471
|
+
return self._binary_path
|
|
472
|
+
except DotenvxInstallError as e:
|
|
473
|
+
raise DotenvxNotFoundError(f"dotenvx not found and auto-install failed: {e}") from e
|
|
474
|
+
|
|
475
|
+
raise DotenvxNotFoundError("dotenvx not found. Install with: envdrift install-dotenvx")
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def binary_path(self) -> Path:
|
|
479
|
+
"""
|
|
480
|
+
Resolve and return the path to the dotenvx executable.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
path (Path): The resolved filesystem path to the dotenvx binary.
|
|
484
|
+
"""
|
|
485
|
+
return self._find_binary()
|
|
486
|
+
|
|
487
|
+
def is_installed(self) -> bool:
|
|
488
|
+
"""
|
|
489
|
+
Determine whether the dotenvx binary is available (will attempt installation when auto_install is enabled).
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
`true` if the dotenvx binary was found or successfully installed, `false` otherwise.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
self._find_binary()
|
|
496
|
+
return True
|
|
497
|
+
except DotenvxNotFoundError:
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
def get_version(self) -> str:
|
|
501
|
+
"""
|
|
502
|
+
Get the installed dotenvx CLI version.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
str: The version string reported by the dotenvx binary (trimmed).
|
|
506
|
+
"""
|
|
507
|
+
result = self._run(["--version"])
|
|
508
|
+
return result.stdout.strip()
|
|
509
|
+
|
|
510
|
+
def _run(
|
|
511
|
+
self,
|
|
512
|
+
args: list[str],
|
|
513
|
+
check: bool = True,
|
|
514
|
+
capture_output: bool = True,
|
|
515
|
+
env: dict[str, str] | None = None,
|
|
516
|
+
cwd: Path | str | None = None,
|
|
517
|
+
) -> subprocess.CompletedProcess:
|
|
518
|
+
"""
|
|
519
|
+
Execute the dotenvx CLI with the provided arguments.
|
|
520
|
+
|
|
521
|
+
Parameters:
|
|
522
|
+
args (list[str]): Arguments to pass to the dotenvx executable (excluding the binary path).
|
|
523
|
+
check (bool): If True, raise DotenvxError when the process exits with a non-zero status.
|
|
524
|
+
capture_output (bool): If True, capture stdout and stderr and include them on the returned CompletedProcess.
|
|
525
|
+
env (dict[str, str] | None): Optional environment mapping to use for the subprocess; defaults to the current environment.
|
|
526
|
+
cwd (Path | str | None): Optional working directory for the subprocess.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
subprocess.CompletedProcess: The finished process result, including returncode, stdout, and stderr.
|
|
530
|
+
|
|
531
|
+
Raises:
|
|
532
|
+
DotenvxError: If the command times out or (when `check` is True) exits with a non-zero status.
|
|
533
|
+
DotenvxNotFoundError: If the dotenvx executable cannot be found.
|
|
534
|
+
"""
|
|
535
|
+
binary = self._find_binary()
|
|
536
|
+
cmd = [str(binary)] + args
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
result = subprocess.run( # nosec B603
|
|
540
|
+
cmd,
|
|
541
|
+
capture_output=capture_output,
|
|
542
|
+
text=True,
|
|
543
|
+
timeout=120,
|
|
544
|
+
env=env,
|
|
545
|
+
cwd=str(cwd) if cwd else None,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
if check and result.returncode != 0:
|
|
549
|
+
raise DotenvxError(
|
|
550
|
+
f"dotenvx command failed (exit {result.returncode}): {result.stderr}"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return result
|
|
554
|
+
except subprocess.TimeoutExpired as e:
|
|
555
|
+
raise DotenvxError("dotenvx command timed out") from e
|
|
556
|
+
except FileNotFoundError as e:
|
|
557
|
+
raise DotenvxNotFoundError(f"dotenvx binary not found: {e}") from e
|
|
558
|
+
|
|
559
|
+
def encrypt(
|
|
560
|
+
self,
|
|
561
|
+
env_file: Path | str,
|
|
562
|
+
env_keys_file: Path | str | None = None,
|
|
563
|
+
env: dict[str, str] | None = None,
|
|
564
|
+
cwd: Path | str | None = None,
|
|
565
|
+
) -> None:
|
|
566
|
+
"""
|
|
567
|
+
Encrypt the specified .env file in place.
|
|
568
|
+
|
|
569
|
+
Parameters:
|
|
570
|
+
env_file (Path | str): Path to the .env file to encrypt.
|
|
571
|
+
env_keys_file (Path | str | None): Optional path to the .env.keys file to use.
|
|
572
|
+
env (dict[str, str] | None): Optional environment variables for the subprocess.
|
|
573
|
+
cwd (Path | str | None): Optional working directory for the subprocess.
|
|
574
|
+
|
|
575
|
+
Raises:
|
|
576
|
+
DotenvxError: If the file does not exist or the encryption command fails.
|
|
577
|
+
"""
|
|
578
|
+
env_file = Path(env_file)
|
|
579
|
+
if not env_file.exists():
|
|
580
|
+
raise DotenvxError(f"File not found: {env_file}")
|
|
581
|
+
|
|
582
|
+
args = ["encrypt", "-f", str(env_file)]
|
|
583
|
+
if env_keys_file:
|
|
584
|
+
args.extend(["-fk", str(env_keys_file)])
|
|
585
|
+
|
|
586
|
+
self._run(args, env=env, cwd=cwd)
|
|
587
|
+
|
|
588
|
+
def decrypt(
|
|
589
|
+
self,
|
|
590
|
+
env_file: Path | str,
|
|
591
|
+
env_keys_file: Path | str | None = None,
|
|
592
|
+
env: dict[str, str] | None = None,
|
|
593
|
+
cwd: Path | str | None = None,
|
|
594
|
+
) -> None:
|
|
595
|
+
"""
|
|
596
|
+
Decrypt the specified dotenv file in place.
|
|
597
|
+
|
|
598
|
+
Parameters:
|
|
599
|
+
env_file (Path | str): Path to the .env file to decrypt.
|
|
600
|
+
env_keys_file (Path | str | None): Optional path to a .env.keys file to use for decryption.
|
|
601
|
+
env (dict[str, str] | None): Optional environment variables to supply to the subprocess.
|
|
602
|
+
cwd (Path | str | None): Optional working directory for the subprocess.
|
|
603
|
+
|
|
604
|
+
Raises:
|
|
605
|
+
DotenvxError: If env_file does not exist or the decryption command fails.
|
|
606
|
+
DotenvxNotFoundError: If the dotenvx binary cannot be located when running the command.
|
|
607
|
+
"""
|
|
608
|
+
env_file = Path(env_file)
|
|
609
|
+
if not env_file.exists():
|
|
610
|
+
raise DotenvxError(f"File not found: {env_file}")
|
|
611
|
+
|
|
612
|
+
args = ["decrypt", "-f", str(env_file)]
|
|
613
|
+
if env_keys_file:
|
|
614
|
+
args.extend(["-fk", str(env_keys_file)])
|
|
615
|
+
|
|
616
|
+
self._run(args, env=env, cwd=cwd)
|
|
617
|
+
|
|
618
|
+
def run(self, env_file: Path | str, command: list[str]) -> subprocess.CompletedProcess:
|
|
619
|
+
"""
|
|
620
|
+
Run the given command with environment variables loaded from the specified env file.
|
|
621
|
+
|
|
622
|
+
The command is executed via the installed dotenvx CLI and will not raise on non-zero exit; inspect the returned CompletedProcess to determine success.
|
|
623
|
+
|
|
624
|
+
Parameters:
|
|
625
|
+
env_file (Path | str): Path to the dotenv file whose variables should be loaded.
|
|
626
|
+
command (list[str]): The command and its arguments to execute (e.g. ["python", "script.py"]).
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
subprocess.CompletedProcess: The completed process result containing return code, stdout, and stderr.
|
|
630
|
+
"""
|
|
631
|
+
env_file = Path(env_file)
|
|
632
|
+
return self._run(["run", "-f", str(env_file), "--"] + command, check=False)
|
|
633
|
+
|
|
634
|
+
def get(self, env_file: Path | str, key: str) -> str | None:
|
|
635
|
+
"""
|
|
636
|
+
Retrieve the value for `key` from the given env file.
|
|
637
|
+
|
|
638
|
+
Parameters:
|
|
639
|
+
env_file (Path | str): Path to the env file to read.
|
|
640
|
+
key (str): Name of the variable to retrieve.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
str | None: Trimmed value of the variable if present, `None` if the key is not present or the command fails.
|
|
644
|
+
"""
|
|
645
|
+
env_file = Path(env_file)
|
|
646
|
+
result = self._run(["get", "-f", str(env_file), key], check=False)
|
|
647
|
+
|
|
648
|
+
if result.returncode != 0:
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
return result.stdout.strip()
|
|
652
|
+
|
|
653
|
+
def set(self, env_file: Path | str, key: str, value: str) -> None:
|
|
654
|
+
"""
|
|
655
|
+
Set a key to the given value in the specified dotenv file.
|
|
656
|
+
|
|
657
|
+
Parameters:
|
|
658
|
+
env_file (Path | str): Path to the .env file to modify.
|
|
659
|
+
key (str): The environment variable name to set.
|
|
660
|
+
value (str): The value to assign to `key`.
|
|
661
|
+
"""
|
|
662
|
+
env_file = Path(env_file)
|
|
663
|
+
self._run(["set", "-f", str(env_file), key, value])
|
|
664
|
+
|
|
665
|
+
@staticmethod
|
|
666
|
+
def install_instructions() -> str:
|
|
667
|
+
"""
|
|
668
|
+
Provide multi-option installation instructions for obtaining the dotenvx CLI.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
str: Multi-line installation instructions containing installation options
|
|
672
|
+
for different scenarios. The pinned version is interpolated into
|
|
673
|
+
the instructions.
|
|
674
|
+
"""
|
|
675
|
+
return f"""
|
|
676
|
+
dotenvx is not installed.
|
|
677
|
+
|
|
678
|
+
Option 1 - Install to ~/.local/bin (recommended):
|
|
679
|
+
curl -sfS "https://dotenvx.sh?directory=$HOME/.local/bin" | sh -s -- --version={DOTENVX_VERSION}
|
|
680
|
+
(Make sure ~/.local/bin is in your PATH)
|
|
681
|
+
|
|
682
|
+
Option 2 - Install to current directory:
|
|
683
|
+
curl -sfS "https://dotenvx.sh?directory=." | sh -s -- --version={DOTENVX_VERSION}
|
|
684
|
+
|
|
685
|
+
Option 3 - System-wide install (requires sudo):
|
|
686
|
+
curl -sfS https://dotenvx.sh | sudo sh -s -- --version={DOTENVX_VERSION}
|
|
687
|
+
|
|
688
|
+
After installing, run your envdrift command again.
|
|
689
|
+
"""
|