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
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)
|