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
envdrift/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Prevent environment variable drift with Pydantic schema validation.
2
+
3
+ envdrift helps you:
4
+ - Validate .env files against Pydantic schemas
5
+ - Detect drift between environments (dev, staging, prod)
6
+ - Integrate with pre-commit hooks and CI/CD pipelines
7
+ - Support dotenvx encryption for secure .env files
8
+ """
9
+
10
+ try:
11
+ from envdrift._version import __version__
12
+ except ImportError:
13
+ # Fallback for editable installs before build
14
+ try:
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ except ImportError:
17
+ # Very old Python without importlib.metadata
18
+ __version__ = "0.0.0+unknown"
19
+ else:
20
+ try:
21
+ __version__ = version("envdrift")
22
+ except PackageNotFoundError:
23
+ __version__ = "0.0.0+unknown"
24
+
25
+ __author__ = "Jainal Gosaliya"
26
+ __email__ = "gosaliya.jainal@gmail.com"
27
+
28
+ from envdrift.api import diff, init, validate
29
+
30
+ __all__ = ["__version__", "diff", "init", "validate"]
envdrift/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '4.2.1'
32
+ __version_tuple__ = version_tuple = (4, 2, 1)
33
+
34
+ __commit_id__ = commit_id = None
envdrift/api.py ADDED
@@ -0,0 +1,192 @@
1
+ """Core functionality for envdrift - high-level API functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from envdrift.core.diff import DiffEngine, DiffResult
8
+ from envdrift.core.parser import EnvParser
9
+ from envdrift.core.schema import SchemaLoader
10
+ from envdrift.core.validator import ValidationResult, Validator
11
+
12
+
13
+ def validate(
14
+ env_file: Path | str = ".env",
15
+ schema: str | None = None,
16
+ service_dir: Path | str | None = None,
17
+ check_encryption: bool = True,
18
+ ) -> ValidationResult:
19
+ """
20
+ Validate an .env file against a Pydantic Settings class schema.
21
+
22
+ Parameters:
23
+ env_file: Path or string to the .env file to validate.
24
+ schema: Dotted path to the Pydantic Settings class (e.g., "app.config:Settings"); required.
25
+ service_dir: Optional directory to add to sys.path to assist importing the schema.
26
+ check_encryption: If true, perform additional checks for encrypted or sensitive values.
27
+
28
+ Returns:
29
+ ValidationResult: Result containing validation status and any issues found.
30
+
31
+ Raises:
32
+ ValueError: If `schema` is not provided.
33
+ FileNotFoundError: If the env file does not exist or cannot be read.
34
+ SchemaLoadError: If the specified schema cannot be imported or loaded.
35
+ """
36
+ if schema is None:
37
+ raise ValueError("schema is required. Example: 'app.config:Settings'")
38
+
39
+ env_file = Path(env_file)
40
+
41
+ # Parse env file
42
+ parser = EnvParser()
43
+ env = parser.parse(env_file)
44
+
45
+ # Load schema
46
+ loader = SchemaLoader()
47
+ settings_cls = loader.load(schema, service_dir)
48
+ schema_meta = loader.extract_metadata(settings_cls)
49
+
50
+ # Validate
51
+ validator = Validator()
52
+ return validator.validate(env, schema_meta, check_encryption=check_encryption)
53
+
54
+
55
+ def diff(
56
+ env1: Path | str,
57
+ env2: Path | str,
58
+ schema: str | None = None,
59
+ service_dir: Path | str | None = None,
60
+ mask_values: bool = True,
61
+ ) -> DiffResult:
62
+ """
63
+ Compute differences between two .env files.
64
+
65
+ Parameters:
66
+ env1 (Path | str): Path to the first .env file.
67
+ env2 (Path | str): Path to the second .env file.
68
+ schema (str | None): Optional dotted path to a Pydantic Settings class used to identify sensitive fields.
69
+ service_dir (Path | str | None): Optional directory to add to imports when loading the schema.
70
+ mask_values (bool): If true, mask sensitive values in the resulting diff.
71
+
72
+ Returns:
73
+ DiffResult: Differences between the files, including added, removed, and changed variables. Sensitive values are masked when requested.
74
+
75
+ Raises:
76
+ FileNotFoundError: If either env1 or env2 does not exist.
77
+ """
78
+ env1 = Path(env1)
79
+ env2 = Path(env2)
80
+
81
+ # Parse env files
82
+ parser = EnvParser()
83
+ env_file1 = parser.parse(env1)
84
+ env_file2 = parser.parse(env2)
85
+
86
+ # Load schema if provided
87
+ schema_meta = None
88
+ if schema:
89
+ loader = SchemaLoader()
90
+ settings_cls = loader.load(schema, service_dir)
91
+ schema_meta = loader.extract_metadata(settings_cls)
92
+
93
+ # Diff
94
+ engine = DiffEngine()
95
+ return engine.diff(env_file1, env_file2, schema=schema_meta, mask_values=mask_values)
96
+
97
+
98
+ def init(
99
+ env_file: Path | str = ".env",
100
+ output: Path | str = "settings.py",
101
+ class_name: str = "Settings",
102
+ detect_sensitive: bool = True,
103
+ ) -> Path:
104
+ """
105
+ Generate a Pydantic BaseSettings subclass file from an existing .env file.
106
+
107
+ Parses the provided env file, optionally detects variables that appear sensitive, and writes a Python module defining a Pydantic Settings class with inferred type hints and defaults. Sensitive fields are marked with `json_schema_extra={"sensitive": True}`.
108
+
109
+ Parameters:
110
+ env_file (Path | str): Path to the source .env file.
111
+ output (Path | str): Path where the generated Python module will be written.
112
+ class_name (str): Name to use for the generated Settings class.
113
+ detect_sensitive (bool): If True, attempt to detect sensitive variables and mark them in the generated fields.
114
+
115
+ Returns:
116
+ Path: The path to the written settings file.
117
+
118
+ Raises:
119
+ FileNotFoundError: If the specified env_file does not exist or cannot be read.
120
+ """
121
+ from envdrift.core.encryption import EncryptionDetector
122
+
123
+ env_file = Path(env_file)
124
+ output = Path(output)
125
+
126
+ # Parse env file
127
+ parser = EnvParser()
128
+ env = parser.parse(env_file)
129
+
130
+ # Detect sensitive variables if requested
131
+ detector = EncryptionDetector()
132
+ sensitive_vars = set()
133
+ if detect_sensitive:
134
+ for var_name, env_var in env.variables.items():
135
+ is_name_sens = detector.is_name_sensitive(var_name)
136
+ is_val_susp = detector.is_value_suspicious(env_var.value)
137
+ if is_name_sens or is_val_susp:
138
+ sensitive_vars.add(var_name)
139
+
140
+ # Generate settings class
141
+ lines = [
142
+ '"""Auto-generated Pydantic Settings class."""',
143
+ "",
144
+ "from pydantic import Field",
145
+ "from pydantic_settings import BaseSettings, SettingsConfigDict",
146
+ "",
147
+ "",
148
+ f"class {class_name}(BaseSettings):",
149
+ f' """Settings generated from {env_file}."""',
150
+ "",
151
+ " model_config = SettingsConfigDict(",
152
+ f' env_file="{env_file}",',
153
+ ' extra="forbid",',
154
+ " )",
155
+ "",
156
+ ]
157
+
158
+ for var_name, env_var in sorted(env.variables.items()):
159
+ is_sensitive = var_name in sensitive_vars
160
+
161
+ # Try to infer type from value
162
+ value = env_var.value
163
+ if value.lower() in ("true", "false"):
164
+ type_hint = "bool"
165
+ default_val = value.lower() == "true"
166
+ elif value.isdigit():
167
+ type_hint = "int"
168
+ default_val = int(value)
169
+ else:
170
+ type_hint = "str"
171
+ default_val = None
172
+
173
+ # Build field
174
+ if is_sensitive:
175
+ extra = 'json_schema_extra={"sensitive": True}'
176
+ if default_val is not None:
177
+ lines.append(
178
+ f" {var_name}: {type_hint} = Field(default={default_val!r}, {extra})"
179
+ )
180
+ else:
181
+ lines.append(f" {var_name}: {type_hint} = Field({extra})")
182
+ else:
183
+ if default_val is not None:
184
+ lines.append(f" {var_name}: {type_hint} = {default_val!r}")
185
+ else:
186
+ lines.append(f" {var_name}: {type_hint}")
187
+
188
+ lines.append("")
189
+
190
+ # Write output
191
+ output.write_text("\n".join(lines))
192
+ return output
envdrift/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ """Command-line interface for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from envdrift.cli_commands.diff import diff
8
+ from envdrift.cli_commands.encryption import decrypt_cmd, encrypt_cmd
9
+ from envdrift.cli_commands.hook import hook
10
+ from envdrift.cli_commands.init_cmd import init as init_cmd
11
+ from envdrift.cli_commands.partial import pull_cmd as pull_partial_cmd
12
+ from envdrift.cli_commands.partial import push as push_cmd
13
+ from envdrift.cli_commands.sync import lock, pull, sync
14
+ from envdrift.cli_commands.validate import validate
15
+ from envdrift.cli_commands.vault import vault_push
16
+ from envdrift.cli_commands.version import version
17
+
18
+ app = typer.Typer(
19
+ name="envdrift",
20
+ help="Prevent environment variable drift with Pydantic schema validation.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ app.command()(validate)
25
+ app.command()(diff)
26
+ app.command("encrypt")(encrypt_cmd)
27
+ app.command("decrypt")(decrypt_cmd)
28
+ app.command("init")(init_cmd)
29
+ app.command()(hook)
30
+ app.command()(sync)
31
+ app.command()(pull)
32
+ app.command()(lock)
33
+ app.command("vault-push")(vault_push)
34
+ app.command()(version)
35
+
36
+ # Partial encryption commands
37
+ app.command("push")(push_cmd)
38
+ app.command("pull-partial")(pull_partial_cmd)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ app()
@@ -0,0 +1 @@
1
+ """Command implementations for the envdrift CLI."""
@@ -0,0 +1,91 @@
1
+ """Diff command for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from envdrift.core.diff import DiffEngine
12
+ from envdrift.core.parser import EnvParser
13
+ from envdrift.core.schema import SchemaLoader, SchemaLoadError
14
+ from envdrift.output.rich import console, print_diff_result, print_error, print_warning
15
+
16
+
17
+ def diff(
18
+ env1: Annotated[Path, typer.Argument(help="First .env file (e.g., .env.dev)")],
19
+ env2: Annotated[Path, typer.Argument(help="Second .env file (e.g., .env.prod)")],
20
+ schema: Annotated[
21
+ str | None,
22
+ typer.Option("--schema", "-s", help="Schema for sensitive field detection"),
23
+ ] = None,
24
+ service_dir: Annotated[
25
+ Path | None,
26
+ typer.Option("--service-dir", "-d", help="Service directory for imports"),
27
+ ] = None,
28
+ show_values: Annotated[
29
+ bool, typer.Option("--show-values", help="Don't mask sensitive values")
30
+ ] = False,
31
+ format_: Annotated[
32
+ str, typer.Option("--format", "-f", help="Output format: table (default), json")
33
+ ] = "table",
34
+ include_unchanged: Annotated[
35
+ bool, typer.Option("--include-unchanged", help="Include unchanged variables")
36
+ ] = False,
37
+ ) -> None:
38
+ """
39
+ Compare two .env files and display their differences.
40
+
41
+ Parameters:
42
+ env1 (Path): Path to the first .env file (e.g., .env.dev).
43
+ env2 (Path): Path to the second .env file (e.g., .env.prod).
44
+ schema (str | None): Optional dotted path to a Pydantic Settings class used to detect sensitive fields; if provided, the schema will be loaded for masking decisions.
45
+ service_dir (Path | None): Optional directory to add to import resolution when loading the schema.
46
+ show_values (bool): If True, do not mask sensitive values in the output.
47
+ format_ (str): Output format, either "table" (default) for human-readable output or "json" for machine-readable output.
48
+ include_unchanged (bool): If True, include variables that are unchanged between the two files in the output.
49
+ """
50
+ # Check files exist
51
+ if not env1.exists():
52
+ print_error(f"ENV file not found: {env1}")
53
+ raise typer.Exit(code=1)
54
+ if not env2.exists():
55
+ print_error(f"ENV file not found: {env2}")
56
+ raise typer.Exit(code=1)
57
+
58
+ # Load schema if provided
59
+ schema_meta = None
60
+ if schema:
61
+ loader = SchemaLoader()
62
+ try:
63
+ settings_cls = loader.load(schema, service_dir)
64
+ schema_meta = loader.extract_metadata(settings_cls)
65
+ except SchemaLoadError as e:
66
+ print_warning(f"Could not load schema: {e}")
67
+
68
+ # Parse env files
69
+ parser = EnvParser()
70
+ try:
71
+ env_file1 = parser.parse(env1)
72
+ env_file2 = parser.parse(env2)
73
+ except FileNotFoundError as e:
74
+ print_error(str(e))
75
+ raise typer.Exit(code=1) from None
76
+
77
+ # Diff
78
+ engine = DiffEngine()
79
+ result = engine.diff(
80
+ env_file1,
81
+ env_file2,
82
+ schema=schema_meta,
83
+ mask_values=not show_values,
84
+ include_unchanged=include_unchanged,
85
+ )
86
+
87
+ # Output
88
+ if format_ == "json":
89
+ console.print_json(json.dumps(engine.to_dict(result), indent=2))
90
+ else:
91
+ print_diff_result(result, show_unchanged=include_unchanged)