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,107 @@
1
+ """Vault client interfaces for multiple backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING
7
+
8
+ from envdrift.vault.base import AuthenticationError, SecretValue, VaultClient, VaultError
9
+
10
+ if TYPE_CHECKING:
11
+ pass
12
+
13
+
14
+ class VaultProvider(Enum):
15
+ """Supported vault providers."""
16
+
17
+ AZURE = "azure"
18
+ AWS = "aws"
19
+ HASHICORP = "hashicorp"
20
+ GCP = "gcp"
21
+
22
+
23
+ def get_vault_client(provider: VaultProvider | str, **config) -> VaultClient:
24
+ """
25
+ Create and return a provider-specific VaultClient configured from the provided keyword arguments.
26
+
27
+ Parameters:
28
+ provider (VaultProvider | str): Vault provider enum or provider name ("azure", "aws", "hashicorp", "gcp").
29
+ **config: Provider-specific configuration:
30
+ - For "azure": `vault_url` (str) — required.
31
+ - For "aws": `region` (str) — optional, defaults to "us-east-1".
32
+ - For "hashicorp": `url` (str) — required; `token` (str) — optional.
33
+ - For "gcp": `project_id` (str) — required.
34
+
35
+ Returns:
36
+ VaultClient: A configured client instance for the requested provider.
37
+
38
+ Raises:
39
+ ImportError: If the provider's optional dependencies are not installed.
40
+ ValueError: If the provider is unsupported or cannot be converted to a VaultProvider.
41
+ """
42
+ if isinstance(provider, str):
43
+ provider = VaultProvider(provider)
44
+
45
+ if provider == VaultProvider.AZURE:
46
+ try:
47
+ from envdrift.vault.azure import AzureKeyVaultClient
48
+ except ImportError as e:
49
+ raise ImportError(
50
+ "Azure vault support requires additional dependencies. "
51
+ "Install with: pip install envdrift[azure]"
52
+ ) from e
53
+ vault_url = config.get("vault_url")
54
+ if not vault_url:
55
+ raise ValueError("Azure vault requires 'vault_url' configuration")
56
+ return AzureKeyVaultClient(vault_url=vault_url)
57
+
58
+ elif provider == VaultProvider.AWS:
59
+ try:
60
+ from envdrift.vault.aws import AWSSecretsManagerClient
61
+ except ImportError as e:
62
+ raise ImportError(
63
+ "AWS vault support requires additional dependencies. "
64
+ "Install with: pip install envdrift[aws]"
65
+ ) from e
66
+ return AWSSecretsManagerClient(region=config.get("region", "us-east-1"))
67
+
68
+ elif provider == VaultProvider.HASHICORP:
69
+ try:
70
+ from envdrift.vault.hashicorp import HashiCorpVaultClient
71
+ except ImportError as e:
72
+ raise ImportError(
73
+ "HashiCorp Vault support requires additional dependencies. "
74
+ "Install with: pip install envdrift[hashicorp]"
75
+ ) from e
76
+ url = config.get("url")
77
+ if not url:
78
+ raise ValueError("HashiCorp Vault requires 'url' configuration")
79
+ return HashiCorpVaultClient(
80
+ url=url,
81
+ token=config.get("token"),
82
+ )
83
+
84
+ elif provider == VaultProvider.GCP:
85
+ try:
86
+ from envdrift.vault.gcp import GCPSecretManagerClient
87
+ except ImportError as e:
88
+ raise ImportError(
89
+ "GCP Secret Manager support requires additional dependencies. "
90
+ "Install with: pip install envdrift[gcp]"
91
+ ) from e
92
+ project_id = config.get("project_id")
93
+ if not project_id:
94
+ raise ValueError("GCP Secret Manager requires 'project_id' configuration")
95
+ return GCPSecretManagerClient(project_id=project_id)
96
+
97
+ raise ValueError(f"Unsupported vault provider: {provider}")
98
+
99
+
100
+ __all__ = [
101
+ "AuthenticationError",
102
+ "SecretValue",
103
+ "VaultClient",
104
+ "VaultError",
105
+ "VaultProvider",
106
+ "get_vault_client",
107
+ ]
envdrift/vault/aws.py ADDED
@@ -0,0 +1,282 @@
1
+ """AWS Secrets Manager client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from envdrift.vault.base import (
8
+ AuthenticationError,
9
+ SecretNotFoundError,
10
+ SecretValue,
11
+ VaultClient,
12
+ VaultError,
13
+ )
14
+
15
+ try:
16
+ import boto3
17
+ from botocore.exceptions import (
18
+ ClientError,
19
+ NoCredentialsError,
20
+ PartialCredentialsError,
21
+ )
22
+
23
+ AWS_AVAILABLE = True
24
+ except ImportError:
25
+ AWS_AVAILABLE = False
26
+ boto3 = None
27
+ ClientError = Exception
28
+ NoCredentialsError = Exception
29
+ PartialCredentialsError = Exception
30
+
31
+
32
+ class AWSSecretsManagerClient(VaultClient):
33
+ """AWS Secrets Manager implementation.
34
+
35
+ Uses boto3's default credential chain which supports:
36
+ - Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
37
+ - Shared credential file (~/.aws/credentials)
38
+ - AWS config file (~/.aws/config)
39
+ - IAM role credentials (EC2, ECS, Lambda)
40
+ """
41
+
42
+ def __init__(self, region: str = "us-east-1"):
43
+ """Initialize AWS Secrets Manager client.
44
+
45
+ Args:
46
+ region: AWS region name
47
+ """
48
+ if not AWS_AVAILABLE:
49
+ raise ImportError("boto3 not installed. Install with: pip install envdrift[aws]")
50
+
51
+ self.region = region
52
+ self._client = None
53
+
54
+ def authenticate(self) -> None:
55
+ """
56
+ Initialize the AWS Secrets Manager client for the configured region and verify access.
57
+
58
+ Raises:
59
+ AuthenticationError: if AWS credentials are missing or incomplete.
60
+ VaultError: if the Secrets Manager service returns an error.
61
+ """
62
+ try:
63
+ self._client = boto3.client(
64
+ "secretsmanager",
65
+ region_name=self.region,
66
+ )
67
+ # Test authentication using get_caller_identity via STS
68
+ # This is more reliable than list_secrets which requires extra permissions
69
+ sts = boto3.client("sts", region_name=self.region)
70
+ sts.get_caller_identity()
71
+ except (NoCredentialsError, PartialCredentialsError) as e:
72
+ raise AuthenticationError(f"AWS authentication failed: {e}") from e
73
+ except ClientError as e:
74
+ error_code = e.response.get("Error", {}).get("Code", "")
75
+ if error_code in ("AccessDenied", "InvalidClientTokenId"):
76
+ raise AuthenticationError(f"AWS authentication failed: {e}") from e
77
+ raise VaultError(f"AWS Secrets Manager error: {e}") from e
78
+
79
+ def is_authenticated(self) -> bool:
80
+ """
81
+ Return whether the AWS Secrets Manager client has been authenticated.
82
+
83
+ This method validates credentials by calling STS get_caller_identity(),
84
+ which ensures that expired or revoked credentials are detected.
85
+
86
+ Returns:
87
+ `true` if the client is authenticated and credentials are valid, `false` otherwise.
88
+ """
89
+ if self._client is None:
90
+ return False
91
+
92
+ # Validate credentials are still valid by calling STS
93
+ try:
94
+ sts = boto3.client("sts", region_name=self.region)
95
+ sts.get_caller_identity()
96
+ return True
97
+ except (NoCredentialsError, PartialCredentialsError, ClientError):
98
+ # Credentials are invalid/expired, reset client state
99
+ self._client = None
100
+ return False
101
+
102
+ def get_secret(self, name: str) -> SecretValue:
103
+ """
104
+ Retrieve a secret from AWS Secrets Manager.
105
+
106
+ Parameters:
107
+ name (str): Secret name or ARN.
108
+
109
+ Returns:
110
+ SecretValue: Contains the secret's name, value, version, and metadata (`arn`, `created_date`, `version_stages`).
111
+
112
+ Raises:
113
+ SecretNotFoundError: If the secret does not exist.
114
+ VaultError: For other AWS Secrets Manager errors.
115
+ """
116
+ self.ensure_authenticated()
117
+
118
+ try:
119
+ response = self._client.get_secret_value(SecretId=name)
120
+
121
+ # Secret can be string or binary
122
+ if "SecretString" in response:
123
+ value = response["SecretString"]
124
+ else:
125
+ # Binary secrets - try UTF-8, fall back to base64
126
+ try:
127
+ value = response["SecretBinary"].decode("utf-8")
128
+ except UnicodeDecodeError:
129
+ import base64
130
+
131
+ value = base64.b64encode(response["SecretBinary"]).decode("ascii")
132
+
133
+ created = response.get("CreatedDate")
134
+ created_str = str(created) if created else None
135
+ return SecretValue(
136
+ name=response.get("Name", name),
137
+ value=value,
138
+ version=response.get("VersionId"),
139
+ metadata={
140
+ "arn": response.get("ARN"),
141
+ "created_date": created_str,
142
+ "version_stages": response.get("VersionStages", []),
143
+ },
144
+ )
145
+ except ClientError as e:
146
+ error_code = e.response.get("Error", {}).get("Code", "")
147
+ if error_code == "ResourceNotFoundException":
148
+ raise SecretNotFoundError(f"Secret '{name}' not found") from e
149
+ raise VaultError(f"AWS Secrets Manager error: {e}") from e
150
+
151
+ def list_secrets(self, prefix: str = "") -> list[str]:
152
+ """
153
+ List secret names stored in AWS Secrets Manager.
154
+
155
+ Parameters:
156
+ prefix (str): Optional name prefix to filter results; only secrets whose names start with this prefix are returned.
157
+
158
+ Returns:
159
+ List of secret names.
160
+ """
161
+ self.ensure_authenticated()
162
+
163
+ try:
164
+ secrets = []
165
+ paginator = self._client.get_paginator("list_secrets")
166
+
167
+ # Note: AWS ListSecrets filter with Key="name" performs exact match,
168
+ # so we fetch all secrets and filter client-side for prefix matching
169
+ for page in paginator.paginate():
170
+ for secret in page.get("SecretList", []):
171
+ name = secret.get("Name")
172
+ if name:
173
+ # Apply client-side prefix filtering
174
+ if not prefix or name.startswith(prefix):
175
+ secrets.append(name)
176
+
177
+ return sorted(secrets)
178
+ except ClientError as e:
179
+ raise VaultError(f"AWS Secrets Manager error: {e}") from e
180
+
181
+ def get_secret_json(self, name: str) -> dict:
182
+ """
183
+ Retrieve the secret identified by `name` and parse its value as a JSON object.
184
+
185
+ Returns:
186
+ dict: Parsed JSON from the secret value.
187
+
188
+ Raises:
189
+ VaultError: If the secret value is not valid JSON.
190
+ """
191
+ secret = self.get_secret(name)
192
+ try:
193
+ return json.loads(secret.value)
194
+ except json.JSONDecodeError as e:
195
+ raise VaultError(f"Secret '{name}' is not valid JSON: {e}") from e
196
+
197
+ def create_secret(self, name: str, value: str, description: str = "") -> SecretValue:
198
+ """
199
+ Create a new secret in AWS Secrets Manager.
200
+
201
+ Parameters:
202
+ name (str): The name to assign to the secret.
203
+ value (str): The secret value to store.
204
+ description (str): Optional human-readable description for the secret.
205
+
206
+ Returns:
207
+ SecretValue: The created secret's representation, including the stored value, version identifier, and metadata (ARN).
208
+
209
+ Raises:
210
+ VaultError: If AWS Secrets Manager returns an error while creating the secret.
211
+ """
212
+ self.ensure_authenticated()
213
+
214
+ try:
215
+ response = self._client.create_secret(
216
+ Name=name,
217
+ SecretString=value,
218
+ Description=description,
219
+ )
220
+ return SecretValue(
221
+ name=response.get("Name", name),
222
+ value=value,
223
+ version=response.get("VersionId"),
224
+ metadata={"arn": response.get("ARN")},
225
+ )
226
+ except ClientError as e:
227
+ raise VaultError(f"AWS Secrets Manager error: {e}") from e
228
+
229
+ def update_secret(self, name: str, value: str) -> SecretValue:
230
+ """
231
+ Update an existing secret in AWS Secrets Manager.
232
+
233
+ Returns:
234
+ SecretValue: Contains the updated secret's name, value, version, and ARN metadata.
235
+
236
+ Raises:
237
+ VaultError: If AWS Secrets Manager returns an error.
238
+ """
239
+ self.ensure_authenticated()
240
+
241
+ try:
242
+ response = self._client.put_secret_value(
243
+ SecretId=name,
244
+ SecretString=value,
245
+ )
246
+ return SecretValue(
247
+ name=response.get("Name", name),
248
+ value=value,
249
+ version=response.get("VersionId"),
250
+ metadata={"arn": response.get("ARN")},
251
+ )
252
+ except ClientError as e:
253
+ raise VaultError(f"AWS Secrets Manager error: {e}") from e
254
+
255
+ def set_secret(self, name: str, value: str) -> SecretValue:
256
+ """
257
+ Create or update a secret in AWS Secrets Manager.
258
+
259
+ Attempts to update the secret first; if it doesn't exist, creates it.
260
+
261
+ Parameters:
262
+ name (str): The name of the secret.
263
+ value (str): The secret value to store.
264
+
265
+ Returns:
266
+ SecretValue: The created or updated secret's representation.
267
+
268
+ Raises:
269
+ VaultError: If AWS Secrets Manager returns an error.
270
+ """
271
+ self.ensure_authenticated()
272
+
273
+ try:
274
+ # Try to update first (most common case)
275
+ return self.update_secret(name, value)
276
+ except VaultError as e:
277
+ # Extract original ClientError if available
278
+ if e.__cause__ and hasattr(e.__cause__, "response"):
279
+ error_code = e.__cause__.response.get("Error", {}).get("Code", "")
280
+ if error_code == "ResourceNotFoundException":
281
+ return self.create_secret(name, value)
282
+ raise
@@ -0,0 +1,170 @@
1
+ """Azure Key Vault client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from envdrift.vault.base import (
6
+ AuthenticationError,
7
+ SecretNotFoundError,
8
+ SecretValue,
9
+ VaultClient,
10
+ VaultError,
11
+ )
12
+
13
+ try:
14
+ from azure.core.exceptions import (
15
+ ClientAuthenticationError,
16
+ HttpResponseError,
17
+ ResourceNotFoundError,
18
+ )
19
+ from azure.identity import DefaultAzureCredential
20
+ from azure.keyvault.secrets import SecretClient
21
+
22
+ AZURE_AVAILABLE = True
23
+ except ImportError:
24
+ AZURE_AVAILABLE = False
25
+ DefaultAzureCredential = None
26
+ SecretClient = None
27
+ ResourceNotFoundError = Exception
28
+ ClientAuthenticationError = Exception
29
+ HttpResponseError = Exception
30
+
31
+
32
+ class AzureKeyVaultClient(VaultClient):
33
+ """Azure Key Vault implementation.
34
+
35
+ Uses DefaultAzureCredential which supports:
36
+ - Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
37
+ - Managed Identity
38
+ - Azure CLI credentials
39
+ - VS Code credentials
40
+ - Interactive browser login
41
+ """
42
+
43
+ def __init__(self, vault_url: str):
44
+ """
45
+ Create an Azure Key Vault client bound to the provided vault URL.
46
+
47
+ Parameters:
48
+ vault_url (str): Vault URL (e.g., "https://my-vault.vault.azure.net/").
49
+
50
+ Raises:
51
+ ImportError: If the Azure SDK is not installed (install with `pip install envdrift[azure]`).
52
+ """
53
+ if not AZURE_AVAILABLE:
54
+ raise ImportError("Azure SDK not installed. Install with: pip install envdrift[azure]")
55
+
56
+ self.vault_url = vault_url
57
+ self._client: SecretClient | None = None
58
+ self._credential = None
59
+
60
+ def authenticate(self) -> None:
61
+ """
62
+ Authenticate to Azure Key Vault using DefaultAzureCredential and initialize the SecretClient.
63
+
64
+ On success sets self._credential to the created credential and self._client to a ready SecretClient.
65
+ Raises AuthenticationError if credential acquisition fails and VaultError for HTTP-related Key Vault errors.
66
+ """
67
+ try:
68
+ self._credential = DefaultAzureCredential()
69
+ self._client = SecretClient(
70
+ vault_url=self.vault_url,
71
+ credential=self._credential,
72
+ )
73
+ # Test authentication by actually consuming one item from the iterator
74
+ # The iterator is lazy and won't authenticate until iterated
75
+ secrets_iter = self._client.list_properties_of_secrets()
76
+ next(iter(secrets_iter), None) # Consume one item to verify auth
77
+ except ClientAuthenticationError as e:
78
+ raise AuthenticationError(f"Azure authentication failed: {e}") from e
79
+ except HttpResponseError as e:
80
+ raise VaultError(f"Azure Key Vault error: {e}") from e
81
+
82
+ def is_authenticated(self) -> bool:
83
+ """
84
+ Return whether the client has an initialized SecretClient and is ready for operations.
85
+
86
+ Returns:
87
+ `true` if the internal client is initialized, `false` otherwise.
88
+ """
89
+ return self._client is not None
90
+
91
+ def get_secret(self, name: str) -> SecretValue:
92
+ """
93
+ Retrieve a secret from the configured Azure Key Vault.
94
+
95
+ Parameters:
96
+ name (str): The name of the secret to retrieve.
97
+
98
+ Returns:
99
+ SecretValue: Contains the secret's name, value, version, and metadata (keys: "enabled", "created_on", "updated_on", "content_type").
100
+
101
+ Raises:
102
+ SecretNotFoundError: If no secret with the given name exists in the vault.
103
+ VaultError: For other Azure Key Vault HTTP errors.
104
+ """
105
+ self.ensure_authenticated()
106
+
107
+ try:
108
+ secret = self._client.get_secret(name)
109
+ props = secret.properties
110
+ created = str(props.created_on) if props.created_on else None
111
+ updated = str(props.updated_on) if props.updated_on else None
112
+ return SecretValue(
113
+ name=name,
114
+ value=secret.value or "",
115
+ version=props.version,
116
+ metadata={
117
+ "enabled": props.enabled,
118
+ "created_on": created,
119
+ "updated_on": updated,
120
+ "content_type": props.content_type,
121
+ },
122
+ )
123
+ except ResourceNotFoundError as e:
124
+ raise SecretNotFoundError(f"Secret '{name}' not found in vault") from e
125
+ except HttpResponseError as e:
126
+ raise VaultError(f"Azure Key Vault error: {e}") from e
127
+
128
+ def list_secrets(self, prefix: str = "") -> list[str]:
129
+ """
130
+ List secret names in the vault, optionally filtered by a prefix.
131
+
132
+ Parameters:
133
+ prefix (str): Optional string; include only secret names that start with this prefix.
134
+
135
+ Returns:
136
+ list[str]: Sorted list of secret names that match the prefix.
137
+ """
138
+ self.ensure_authenticated()
139
+
140
+ try:
141
+ secrets = []
142
+ for secret_properties in self._client.list_properties_of_secrets():
143
+ name = secret_properties.name
144
+ if name and (not prefix or name.startswith(prefix)):
145
+ secrets.append(name)
146
+ return sorted(secrets)
147
+ except HttpResponseError as e:
148
+ raise VaultError(f"Azure Key Vault error: {e}") from e
149
+
150
+ def set_secret(self, name: str, value: str) -> SecretValue:
151
+ """
152
+ Store or update a secret in Azure Key Vault.
153
+
154
+ Returns:
155
+ SecretValue containing the stored secret's name, value, version, and metadata (includes `enabled`).
156
+ """
157
+ self.ensure_authenticated()
158
+
159
+ try:
160
+ secret = self._client.set_secret(name, value)
161
+ return SecretValue(
162
+ name=name,
163
+ value=secret.value or "",
164
+ version=secret.properties.version,
165
+ metadata={
166
+ "enabled": secret.properties.enabled,
167
+ },
168
+ )
169
+ except HttpResponseError as e:
170
+ raise VaultError(f"Azure Key Vault error: {e}") from e