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,266 @@
1
+ """Pre-commit hook integration for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ # Pre-commit hook configuration template
8
+ HOOK_CONFIG = """# envdrift pre-commit hooks
9
+ # Add this to your .pre-commit-config.yaml
10
+
11
+ repos:
12
+ - repo: local
13
+ hooks:
14
+ - id: envdrift-validate
15
+ name: Validate env files against schema
16
+ entry: envdrift validate --ci
17
+ language: system
18
+ files: ^\\.env\\.(production|staging|development)$
19
+ pass_filenames: true
20
+ description: Validates .env files match Pydantic schema
21
+
22
+ - id: envdrift-encryption
23
+ name: Check env encryption status
24
+ entry: envdrift encrypt --check
25
+ language: system
26
+ files: ^\\.env\\.(production|staging)$
27
+ pass_filenames: true
28
+ description: Ensures sensitive .env files are encrypted
29
+ """
30
+
31
+ # Minimal hook entry for injection
32
+ HOOK_ENTRY = {
33
+ "repo": "local",
34
+ "hooks": [
35
+ {
36
+ "id": "envdrift-validate",
37
+ "name": "Validate env files against schema",
38
+ "entry": "envdrift validate --ci",
39
+ "language": "system",
40
+ "files": r"^\.env\.(production|staging|development)$",
41
+ "pass_filenames": True,
42
+ },
43
+ {
44
+ "id": "envdrift-encryption",
45
+ "name": "Check env encryption status",
46
+ "entry": "envdrift encrypt --check",
47
+ "language": "system",
48
+ "files": r"^\.env\.(production|staging)$",
49
+ "pass_filenames": True,
50
+ },
51
+ ],
52
+ }
53
+
54
+
55
+ def get_hook_config() -> str:
56
+ """
57
+ Provide the default pre-commit hook configuration template for envdrift.
58
+
59
+ Returns:
60
+ The YAML string containing the pre-commit configuration for envdrift hooks.
61
+ """
62
+ return HOOK_CONFIG
63
+
64
+
65
+ def find_precommit_config(start_dir: Path | None = None) -> Path | None:
66
+ """
67
+ Locate a .pre-commit-config.yaml file by searching the given directory and its parents.
68
+
69
+ Parameters:
70
+ start_dir (Path | None): Directory to start the search from. If None, the current working directory is used.
71
+
72
+ Returns:
73
+ Path | None: Path to the first .pre-commit-config.yaml found while walking upward, or `None` if no file is found.
74
+ """
75
+ if start_dir is None:
76
+ start_dir = Path.cwd()
77
+
78
+ current = start_dir.resolve()
79
+
80
+ while True:
81
+ config_path = current / ".pre-commit-config.yaml"
82
+ if config_path.exists():
83
+ return config_path
84
+ if current == current.parent:
85
+ # Reached filesystem root
86
+ break
87
+ current = current.parent
88
+
89
+ return None
90
+
91
+
92
+ def install_hooks(
93
+ config_path: Path | None = None,
94
+ create_if_missing: bool = True,
95
+ ) -> bool:
96
+ """Install envdrift hooks to .pre-commit-config.yaml.
97
+
98
+ Args:
99
+ config_path: Path to pre-commit config (auto-detected if None)
100
+ create_if_missing: Create config file if it doesn't exist
101
+
102
+ Returns:
103
+ True if hooks were installed/updated
104
+
105
+ Raises:
106
+ FileNotFoundError: If config not found and create_if_missing=False
107
+ """
108
+ try:
109
+ import yaml
110
+ except ImportError:
111
+ raise ImportError(
112
+ "PyYAML is required for pre-commit integration. Install with: pip install pyyaml"
113
+ )
114
+
115
+ # Find or create config
116
+ if config_path is None:
117
+ config_path = find_precommit_config()
118
+
119
+ if config_path is None:
120
+ if create_if_missing:
121
+ config_path = Path.cwd() / ".pre-commit-config.yaml"
122
+ else:
123
+ raise FileNotFoundError(
124
+ ".pre-commit-config.yaml not found. "
125
+ "Run from repository root or specify --config path."
126
+ )
127
+
128
+ # Load existing config or create new
129
+ if config_path.exists():
130
+ content = config_path.read_text()
131
+ config = yaml.safe_load(content) or {}
132
+ else:
133
+ config = {}
134
+
135
+ # Initialize repos list if needed
136
+ if "repos" not in config:
137
+ config["repos"] = []
138
+
139
+ # Check if envdrift hooks already exist
140
+ has_envdrift = False
141
+ for repo in config["repos"]:
142
+ if repo.get("repo") == "local":
143
+ hooks = repo.get("hooks", [])
144
+ for hook in hooks:
145
+ if hook.get("id", "").startswith("envdrift-"):
146
+ has_envdrift = True
147
+ break
148
+
149
+ # Add hooks if not present
150
+ if not has_envdrift:
151
+ # Find existing local repo or create new one
152
+ local_repo = None
153
+ for repo in config["repos"]:
154
+ if repo.get("repo") == "local":
155
+ local_repo = repo
156
+ break
157
+
158
+ if local_repo:
159
+ # Add to existing local repo
160
+ if "hooks" not in local_repo:
161
+ local_repo["hooks"] = []
162
+ local_repo["hooks"].extend(HOOK_ENTRY["hooks"])
163
+ else:
164
+ # Add new local repo entry
165
+ config["repos"].append(HOOK_ENTRY)
166
+
167
+ # Write config
168
+ with open(config_path, "w") as f:
169
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
170
+
171
+ return True
172
+
173
+
174
+ def uninstall_hooks(config_path: Path | None = None) -> bool:
175
+ """
176
+ Remove any envdrift hooks from a .pre-commit-config.yaml file.
177
+
178
+ Parameters:
179
+ config_path (Path | None): Path to the pre-commit config file. If None, the repository tree is searched upward to locate .pre-commit-config.yaml.
180
+
181
+ Returns:
182
+ bool: `True` if one or more envdrift hooks were removed and the file was updated, `False` otherwise.
183
+
184
+ Raises:
185
+ ImportError: If PyYAML is not available.
186
+ """
187
+ try:
188
+ import yaml
189
+ except ImportError:
190
+ raise ImportError("PyYAML is required for pre-commit integration.")
191
+
192
+ if config_path is None:
193
+ config_path = find_precommit_config()
194
+
195
+ if config_path is None or not config_path.exists():
196
+ return False
197
+
198
+ content = config_path.read_text()
199
+ config = yaml.safe_load(content) or {}
200
+
201
+ if "repos" not in config:
202
+ return False
203
+
204
+ modified = False
205
+
206
+ for repo in config["repos"]:
207
+ if repo.get("repo") == "local":
208
+ hooks = repo.get("hooks", [])
209
+ original_count = len(hooks)
210
+
211
+ # Remove envdrift hooks
212
+ repo["hooks"] = [
213
+ hook for hook in hooks if not hook.get("id", "").startswith("envdrift-")
214
+ ]
215
+
216
+ if len(repo["hooks"]) != original_count:
217
+ modified = True
218
+
219
+ if modified:
220
+ # Remove empty local repos
221
+ config["repos"] = [
222
+ repo
223
+ for repo in config["repos"]
224
+ if not (repo.get("repo") == "local" and not repo.get("hooks"))
225
+ ]
226
+
227
+ with open(config_path, "w") as f:
228
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
229
+
230
+ return modified
231
+
232
+
233
+ def verify_hooks_installed(config_path: Path | None = None) -> dict[str, bool]:
234
+ """
235
+ Check which envdrift pre-commit hooks are present in a given pre-commit configuration.
236
+
237
+ Parameters:
238
+ config_path (Path | None): Path to a .pre-commit-config.yaml file. If None, the file is searched for by walking up from the current working directory.
239
+
240
+ Returns:
241
+ dict[str, bool]: Mapping of hook id to installation status: `{"envdrift-validate": bool, "envdrift-encryption": bool}`. Returns both `False` if the config file is missing or unreadable, or if the PyYAML package is not available.
242
+ """
243
+ try:
244
+ import yaml
245
+ except ImportError:
246
+ return {"envdrift-validate": False, "envdrift-encryption": False}
247
+
248
+ if config_path is None:
249
+ config_path = find_precommit_config()
250
+
251
+ if config_path is None or not config_path.exists():
252
+ return {"envdrift-validate": False, "envdrift-encryption": False}
253
+
254
+ content = config_path.read_text()
255
+ config = yaml.safe_load(content) or {}
256
+
257
+ result = {"envdrift-validate": False, "envdrift-encryption": False}
258
+
259
+ for repo in config.get("repos", []):
260
+ if repo.get("repo") == "local":
261
+ for hook in repo.get("hooks", []):
262
+ hook_id = hook.get("id", "")
263
+ if hook_id in result:
264
+ result[hook_id] = True
265
+
266
+ return result
@@ -0,0 +1,85 @@
1
+ """SOPS installer helpers for optional auto-install."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import stat
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from envdrift.integrations.dotenvx import get_platform_info, get_venv_bin_dir
12
+
13
+
14
+ class SopsInstallError(Exception):
15
+ """Failed to install SOPS."""
16
+
17
+ pass
18
+
19
+
20
+ def _load_constants() -> dict:
21
+ constants_path = Path(__file__).parent.parent / "constants.json"
22
+ with open(constants_path) as f:
23
+ return json.load(f)
24
+
25
+
26
+ def _get_sops_version() -> str:
27
+ return _load_constants()["sops_version"]
28
+
29
+
30
+ def _get_download_url_templates() -> dict[str, str]:
31
+ return _load_constants()["sops_download_urls"]
32
+
33
+
34
+ SOPS_VERSION = _get_sops_version()
35
+
36
+ _URL_TEMPLATES = _get_download_url_templates()
37
+ SOPS_DOWNLOAD_URLS = {
38
+ ("Darwin", "x86_64"): _URL_TEMPLATES["darwin_amd64"],
39
+ ("Darwin", "arm64"): _URL_TEMPLATES["darwin_arm64"],
40
+ ("Linux", "x86_64"): _URL_TEMPLATES["linux_amd64"],
41
+ ("Linux", "aarch64"): _URL_TEMPLATES["linux_arm64"],
42
+ ("Windows", "AMD64"): _URL_TEMPLATES["windows_amd64"],
43
+ ("Windows", "x86_64"): _URL_TEMPLATES["windows_amd64"],
44
+ }
45
+
46
+
47
+ def get_sops_path() -> Path:
48
+ bin_dir = get_venv_bin_dir()
49
+ binary_name = "sops.exe" if platform.system() == "Windows" else "sops"
50
+ return bin_dir / binary_name
51
+
52
+
53
+ class SopsInstaller:
54
+ """Install SOPS binary to the virtual environment or user bin directory."""
55
+
56
+ def __init__(self, version: str = SOPS_VERSION):
57
+ self.version = version
58
+
59
+ def _get_download_url(self) -> str:
60
+ system, machine = get_platform_info()
61
+ template = SOPS_DOWNLOAD_URLS.get((system, machine))
62
+ if not template:
63
+ raise SopsInstallError(f"Unsupported platform: {system} {machine}")
64
+ return template.format(version=self.version)
65
+
66
+ def install(self, target_path: Path | None = None) -> Path:
67
+ if target_path is None:
68
+ target_path = get_sops_path()
69
+
70
+ target_path.parent.mkdir(parents=True, exist_ok=True)
71
+ url = self._get_download_url()
72
+ tmp_path = target_path.with_suffix(target_path.suffix + ".download")
73
+
74
+ try:
75
+ urllib.request.urlretrieve(url, tmp_path) # nosec B310
76
+ if platform.system() != "Windows":
77
+ st = tmp_path.stat()
78
+ tmp_path.chmod(st.st_mode | stat.S_IEXEC)
79
+ tmp_path.replace(target_path)
80
+ except Exception as e: # nosec B110
81
+ if tmp_path.exists():
82
+ tmp_path.unlink()
83
+ raise SopsInstallError(f"Failed to install SOPS: {e}") from e
84
+
85
+ return target_path
@@ -0,0 +1,21 @@
1
+ """Output formatting modules."""
2
+
3
+ from envdrift.output.rich import (
4
+ console,
5
+ print_diff_result,
6
+ print_encryption_report,
7
+ print_error,
8
+ print_success,
9
+ print_validation_result,
10
+ print_warning,
11
+ )
12
+
13
+ __all__ = [
14
+ "console",
15
+ "print_diff_result",
16
+ "print_encryption_report",
17
+ "print_error",
18
+ "print_success",
19
+ "print_validation_result",
20
+ "print_warning",
21
+ ]