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,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
+ """