envcontract 0.1.0__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.
- envcontract/__init__.py +3 -0
- envcontract/cli.py +138 -0
- envcontract/drift.py +33 -0
- envcontract/generate.py +53 -0
- envcontract/guard.py +54 -0
- envcontract/parser.py +115 -0
- envcontract/report.py +58 -0
- envcontract/schema.py +75 -0
- envcontract/secrets.py +29 -0
- envcontract/validators.py +128 -0
- envcontract-0.1.0.dist-info/METADATA +61 -0
- envcontract-0.1.0.dist-info/RECORD +15 -0
- envcontract-0.1.0.dist-info/WHEEL +4 -0
- envcontract-0.1.0.dist-info/entry_points.txt +2 -0
- envcontract-0.1.0.dist-info/licenses/LICENSE +21 -0
envcontract/__init__.py
ADDED
envcontract/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Command-line entry point for envcontract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .drift import compute_drift
|
|
13
|
+
from .generate import render_schema_yaml
|
|
14
|
+
from .guard import scan_files
|
|
15
|
+
from .parser import parse_file
|
|
16
|
+
from .report import render_human, render_json
|
|
17
|
+
from .schema import EnvSchema, SchemaError
|
|
18
|
+
from .validators import validate
|
|
19
|
+
|
|
20
|
+
_ENV_OPT = dict(default=".env", show_default=True, help="Path to the .env file.")
|
|
21
|
+
_SCHEMA_OPT = dict(default=".env.schema", show_default=True, help="Path to the .env.schema file.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
25
|
+
@click.version_option(__version__, "-V", "--version", prog_name="envcontract")
|
|
26
|
+
def cli() -> None:
|
|
27
|
+
"""envcontract - the contract for your .env.
|
|
28
|
+
|
|
29
|
+
Validate your .env against a committed schema, catch team drift, and
|
|
30
|
+
never commit a secret. 100% local: your values never leave your machine.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_schema_or_exit(schema_path: str, console: Console) -> EnvSchema:
|
|
35
|
+
try:
|
|
36
|
+
return EnvSchema.from_file(schema_path)
|
|
37
|
+
except SchemaError as exc:
|
|
38
|
+
console.print(f"[red]X[/red] {exc}")
|
|
39
|
+
sys.exit(2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cli.command()
|
|
43
|
+
@click.option("--env", "env_path", **_ENV_OPT)
|
|
44
|
+
@click.option("--schema", "schema_path", **_SCHEMA_OPT)
|
|
45
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing schema file.")
|
|
46
|
+
def init(env_path: str, schema_path: str, force: bool) -> None:
|
|
47
|
+
"""Generate a .env.schema from an existing .env (values stripped)."""
|
|
48
|
+
console = Console()
|
|
49
|
+
if not Path(env_path).exists():
|
|
50
|
+
Console(stderr=True).print(f"[red]X[/red] env file not found: {env_path}")
|
|
51
|
+
sys.exit(2)
|
|
52
|
+
if Path(schema_path).exists() and not force:
|
|
53
|
+
Console(stderr=True).print(
|
|
54
|
+
f"[yellow]![/yellow] {schema_path} already exists. Use --force to overwrite."
|
|
55
|
+
)
|
|
56
|
+
sys.exit(2)
|
|
57
|
+
|
|
58
|
+
yaml_text = render_schema_yaml(parse_file(env_path))
|
|
59
|
+
Path(schema_path).write_text(yaml_text, encoding="utf-8")
|
|
60
|
+
n = yaml_text.count("\n type:")
|
|
61
|
+
console.print(f"[green]+[/green] Wrote {schema_path} with {n} variable(s). No values were copied.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cli.command()
|
|
65
|
+
@click.option("--env", "env_path", **_ENV_OPT)
|
|
66
|
+
@click.option("--schema", "schema_path", **_SCHEMA_OPT)
|
|
67
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON (for CI).")
|
|
68
|
+
def check(env_path: str, schema_path: str, as_json: bool) -> None:
|
|
69
|
+
"""Validate a .env against the schema (types, rules, required keys)."""
|
|
70
|
+
err_console = Console(stderr=True)
|
|
71
|
+
if not Path(env_path).exists():
|
|
72
|
+
err_console.print(f"[red]X[/red] env file not found: {env_path}")
|
|
73
|
+
sys.exit(2)
|
|
74
|
+
schema = _load_schema_or_exit(schema_path, err_console)
|
|
75
|
+
|
|
76
|
+
findings = validate(parse_file(env_path), schema)
|
|
77
|
+
if as_json:
|
|
78
|
+
click.echo(render_json(findings))
|
|
79
|
+
else:
|
|
80
|
+
render_human(findings, Console())
|
|
81
|
+
sys.exit(1 if any(f.severity.value == "error" for f in findings) else 0)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@cli.command()
|
|
85
|
+
@click.option("--env", "env_path", **_ENV_OPT)
|
|
86
|
+
@click.option("--schema", "schema_path", **_SCHEMA_OPT)
|
|
87
|
+
def diff(env_path: str, schema_path: str) -> None:
|
|
88
|
+
"""Show what your local .env has vs. the schema (and vice versa)."""
|
|
89
|
+
console = Console()
|
|
90
|
+
err_console = Console(stderr=True)
|
|
91
|
+
if not Path(env_path).exists():
|
|
92
|
+
err_console.print(f"[red]X[/red] env file not found: {env_path}")
|
|
93
|
+
sys.exit(2)
|
|
94
|
+
schema = _load_schema_or_exit(schema_path, err_console)
|
|
95
|
+
|
|
96
|
+
d = compute_drift(parse_file(env_path), schema)
|
|
97
|
+
if not d.has_drift:
|
|
98
|
+
console.print(f"[green]+[/green] In sync: {len(d.in_sync)} variable(s) match the schema.")
|
|
99
|
+
sys.exit(0)
|
|
100
|
+
|
|
101
|
+
if d.missing_locally:
|
|
102
|
+
console.print("[red]Missing from your .env (declared in schema):[/red]")
|
|
103
|
+
for k in d.missing_locally:
|
|
104
|
+
console.print(f" [red]-[/red] {k}")
|
|
105
|
+
if d.not_in_schema:
|
|
106
|
+
console.print("[yellow]In your .env but not in the schema:[/yellow]")
|
|
107
|
+
for k in d.not_in_schema:
|
|
108
|
+
console.print(f" [yellow]+[/yellow] {k}")
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@cli.command()
|
|
113
|
+
@click.argument("files", nargs=-1)
|
|
114
|
+
@click.option("--schema", "schema_path", **_SCHEMA_OPT)
|
|
115
|
+
def guard(files: tuple[str, ...], schema_path: str) -> None:
|
|
116
|
+
"""Pre-commit hook: block committing real values for secret keys."""
|
|
117
|
+
console = Console(stderr=True)
|
|
118
|
+
schema = None
|
|
119
|
+
if Path(schema_path).exists():
|
|
120
|
+
try:
|
|
121
|
+
schema = EnvSchema.from_file(schema_path)
|
|
122
|
+
except SchemaError:
|
|
123
|
+
schema = None
|
|
124
|
+
|
|
125
|
+
violations = scan_files(list(files), schema)
|
|
126
|
+
if not violations:
|
|
127
|
+
sys.exit(0)
|
|
128
|
+
|
|
129
|
+
console.print("[red]X envcontract: blocked commit - real secret values detected:[/red]")
|
|
130
|
+
for v in violations:
|
|
131
|
+
loc = f"{v.file}:{v.line_no}" if v.line_no else v.file
|
|
132
|
+
console.print(f" [red]-[/red] {v.key} ({loc})")
|
|
133
|
+
console.print("\nRemove these values (or move them to a git-ignored .env) before committing.")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
cli()
|
envcontract/drift.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Compute drift between a local .env and the committed schema.
|
|
2
|
+
|
|
3
|
+
Answers the "a teammate added a var and didn't tell anyone" problem.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .parser import ParsedEnv
|
|
11
|
+
from .schema import EnvSchema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Drift:
|
|
16
|
+
missing_locally: list[str] # declared in schema, absent from local .env
|
|
17
|
+
not_in_schema: list[str] # present locally, not declared in schema
|
|
18
|
+
in_sync: list[str] # present in both
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def has_drift(self) -> bool:
|
|
22
|
+
return bool(self.missing_locally or self.not_in_schema)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compute_drift(parsed: ParsedEnv, schema: EnvSchema) -> Drift:
|
|
26
|
+
env_keys = list(parsed.as_dict().keys())
|
|
27
|
+
schema_keys = list(schema.variables.keys())
|
|
28
|
+
env_set, schema_set = set(env_keys), set(schema_keys)
|
|
29
|
+
|
|
30
|
+
missing_locally = [k for k in schema_keys if k not in env_set]
|
|
31
|
+
not_in_schema = [k for k in env_keys if k not in schema_set]
|
|
32
|
+
in_sync = [k for k in schema_keys if k in env_set]
|
|
33
|
+
return Drift(missing_locally, not_in_schema, in_sync)
|
envcontract/generate.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Generate a .env.schema from an existing .env.
|
|
2
|
+
|
|
3
|
+
Infers a type per variable, flags likely secrets, and — critically — never
|
|
4
|
+
writes any value into the schema. The schema is a contract, not a secret store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .parser import ParsedEnv
|
|
12
|
+
from .secrets import looks_like_secret_key
|
|
13
|
+
|
|
14
|
+
_URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s]+$")
|
|
15
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
16
|
+
_BOOL_VALUES = {"true", "false", "yes", "no", "on", "off"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def infer_type(value: str) -> str:
|
|
20
|
+
v = value.strip()
|
|
21
|
+
if v.lower() in _BOOL_VALUES:
|
|
22
|
+
return "bool"
|
|
23
|
+
if _URL_RE.match(v):
|
|
24
|
+
return "url"
|
|
25
|
+
if _EMAIL_RE.match(v):
|
|
26
|
+
return "email"
|
|
27
|
+
if re.fullmatch(r"[+-]?\d+", v):
|
|
28
|
+
return "int"
|
|
29
|
+
if re.fullmatch(r"[+-]?\d*\.\d+", v):
|
|
30
|
+
return "float"
|
|
31
|
+
return "string"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_schema_yaml(parsed: ParsedEnv) -> str:
|
|
35
|
+
"""Build a clean, deterministic .env.schema YAML (values stripped)."""
|
|
36
|
+
lines = [
|
|
37
|
+
"# Generated by `envcontract init`. Commit this file; it contains no secret values.",
|
|
38
|
+
"version: 1",
|
|
39
|
+
"variables:",
|
|
40
|
+
]
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
for entry in parsed.entries:
|
|
43
|
+
if entry.key in seen:
|
|
44
|
+
continue
|
|
45
|
+
seen.add(entry.key)
|
|
46
|
+
vtype = infer_type(entry.value)
|
|
47
|
+
secret = looks_like_secret_key(entry.key)
|
|
48
|
+
lines.append(f" {entry.key}:")
|
|
49
|
+
lines.append(f" type: {vtype}")
|
|
50
|
+
lines.append(" required: true")
|
|
51
|
+
if secret:
|
|
52
|
+
lines.append(" secret: true")
|
|
53
|
+
return "\n".join(lines) + "\n"
|
envcontract/guard.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Detect real secret values about to be committed.
|
|
2
|
+
|
|
3
|
+
Narrow by design: we only flag keys the schema marks ``secret: true`` that
|
|
4
|
+
carry a real (non-placeholder, non-empty) value. We never print the value.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .parser import parse_file
|
|
13
|
+
from .schema import EnvSchema
|
|
14
|
+
from .secrets import looks_like_secret_key
|
|
15
|
+
|
|
16
|
+
# Common placeholders that are safe to commit.
|
|
17
|
+
_PLACEHOLDERS = {
|
|
18
|
+
"", "changeme", "change_me", "your_value_here", "xxx", "todo",
|
|
19
|
+
"placeholder", "example", "secret", "<your-key>", "...",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Violation:
|
|
25
|
+
file: str
|
|
26
|
+
key: str
|
|
27
|
+
line_no: int | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_real_secret_value(value: str) -> bool:
|
|
31
|
+
v = value.strip().lower()
|
|
32
|
+
if v in _PLACEHOLDERS:
|
|
33
|
+
return False
|
|
34
|
+
return len(value.strip()) >= 6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan_files(files: list[str], schema: EnvSchema | None) -> list[Violation]:
|
|
38
|
+
"""Return violations: secret-marked keys carrying a real value in staged files."""
|
|
39
|
+
secret_keys = {k for k, r in schema.variables.items() if r.secret} if schema else set()
|
|
40
|
+
violations: list[Violation] = []
|
|
41
|
+
|
|
42
|
+
for f in files:
|
|
43
|
+
path = Path(f)
|
|
44
|
+
if not path.exists() or not path.is_file():
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
parsed = parse_file(path)
|
|
48
|
+
except (UnicodeDecodeError, OSError):
|
|
49
|
+
continue
|
|
50
|
+
for entry in parsed.entries:
|
|
51
|
+
is_secret = entry.key in secret_keys or looks_like_secret_key(entry.key)
|
|
52
|
+
if is_secret and _is_real_secret_value(entry.value):
|
|
53
|
+
violations.append(Violation(file=f, key=entry.key, line_no=entry.line_no))
|
|
54
|
+
return violations
|
envcontract/parser.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Parse .env files into ordered key/value entries with line numbers.
|
|
2
|
+
|
|
3
|
+
Aims to be compatible with python-dotenv conventions: ``export`` prefixes,
|
|
4
|
+
single/double quotes, inline comments on unquoted values, and blank/comment
|
|
5
|
+
lines. We deliberately keep this small and predictable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class EnvEntry:
|
|
16
|
+
"""A single ``KEY=value`` line from a .env file."""
|
|
17
|
+
|
|
18
|
+
key: str
|
|
19
|
+
value: str
|
|
20
|
+
line_no: int
|
|
21
|
+
quoted: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ParseError:
|
|
26
|
+
"""A malformed line we could not interpret as a key/value pair."""
|
|
27
|
+
|
|
28
|
+
line_no: int
|
|
29
|
+
raw: str
|
|
30
|
+
message: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ParsedEnv:
|
|
35
|
+
entries: list[EnvEntry]
|
|
36
|
+
errors: list[ParseError]
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> dict[str, str]:
|
|
39
|
+
"""Later entries win, matching how shells/dotenv resolve duplicates."""
|
|
40
|
+
return {e.key: e.value for e in self.entries}
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def duplicate_keys(self) -> list[str]:
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
dupes: list[str] = []
|
|
46
|
+
for e in self.entries:
|
|
47
|
+
if e.key in seen and e.key not in dupes:
|
|
48
|
+
dupes.append(e.key)
|
|
49
|
+
seen.add(e.key)
|
|
50
|
+
return dupes
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_inline_comment(value: str) -> str:
|
|
54
|
+
"""Remove an unquoted trailing ``# comment`` (must be preceded by space)."""
|
|
55
|
+
result = []
|
|
56
|
+
for i, ch in enumerate(value):
|
|
57
|
+
if ch == "#" and (i == 0 or value[i - 1].isspace()):
|
|
58
|
+
break
|
|
59
|
+
result.append(ch)
|
|
60
|
+
return "".join(result).strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_value(raw: str) -> tuple[str, bool]:
|
|
64
|
+
"""Return (value, quoted). Handles single/double quotes and inline comments."""
|
|
65
|
+
raw = raw.strip()
|
|
66
|
+
if not raw:
|
|
67
|
+
return "", False
|
|
68
|
+
if raw[0] in {'"', "'"}:
|
|
69
|
+
quote = raw[0]
|
|
70
|
+
end = raw.find(quote, 1)
|
|
71
|
+
if end != -1:
|
|
72
|
+
inner = raw[1:end]
|
|
73
|
+
if quote == '"':
|
|
74
|
+
inner = inner.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"')
|
|
75
|
+
return inner, True
|
|
76
|
+
# Unterminated quote: treat the rest literally.
|
|
77
|
+
return raw[1:], True
|
|
78
|
+
return _strip_inline_comment(raw), False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_text(text: str) -> ParsedEnv:
|
|
82
|
+
entries: list[EnvEntry] = []
|
|
83
|
+
errors: list[ParseError] = []
|
|
84
|
+
|
|
85
|
+
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
|
86
|
+
line = raw_line.strip()
|
|
87
|
+
if not line or line.startswith("#"):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if line.startswith("export "):
|
|
91
|
+
line = line[len("export ") :].lstrip()
|
|
92
|
+
|
|
93
|
+
if "=" not in line:
|
|
94
|
+
errors.append(ParseError(line_no, raw_line, "missing '=' (not a KEY=value line)"))
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
key, _, value_part = line.partition("=")
|
|
98
|
+
key = key.strip()
|
|
99
|
+
if not key:
|
|
100
|
+
errors.append(ParseError(line_no, raw_line, "empty key before '='"))
|
|
101
|
+
continue
|
|
102
|
+
if not all(c.isalnum() or c == "_" for c in key):
|
|
103
|
+
errors.append(
|
|
104
|
+
ParseError(line_no, raw_line, f"invalid key name '{key}' (use A-Z, 0-9, _)")
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
value, quoted = _parse_value(value_part)
|
|
109
|
+
entries.append(EnvEntry(key=key, value=value, line_no=line_no, quoted=quoted))
|
|
110
|
+
|
|
111
|
+
return ParsedEnv(entries=entries, errors=errors)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_file(path: str | Path) -> ParsedEnv:
|
|
115
|
+
return parse_text(Path(path).read_text(encoding="utf-8"))
|
envcontract/report.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Render validation findings as human-friendly terminal output or JSON.
|
|
2
|
+
|
|
3
|
+
Invariant: this module never prints a secret value. Findings carry messages,
|
|
4
|
+
not raw values, and any value echoed elsewhere is masked first.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from .validators import Finding, Severity
|
|
15
|
+
|
|
16
|
+
_ICON = {Severity.ERROR: "[red]✗[/red]", Severity.WARNING: "[yellow]![/yellow]"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_human(findings: list[Finding], console: Console | None = None) -> None:
|
|
20
|
+
console = console or Console()
|
|
21
|
+
errors = [f for f in findings if f.severity is Severity.ERROR]
|
|
22
|
+
warnings = [f for f in findings if f.severity is Severity.WARNING]
|
|
23
|
+
|
|
24
|
+
if not findings:
|
|
25
|
+
console.print("[bold green]✓ All variables valid.[/bold green] Your .env matches the schema.")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
table = Table(show_header=True, header_style="bold", expand=False)
|
|
29
|
+
table.add_column("")
|
|
30
|
+
table.add_column("Variable", style="cyan", no_wrap=True)
|
|
31
|
+
table.add_column("Line", justify="right", style="dim")
|
|
32
|
+
table.add_column("Issue")
|
|
33
|
+
for f in findings:
|
|
34
|
+
table.add_row(_ICON[f.severity], f.key, str(f.line_no or "-"), f.message)
|
|
35
|
+
console.print(table)
|
|
36
|
+
|
|
37
|
+
parts = []
|
|
38
|
+
if errors:
|
|
39
|
+
parts.append(f"[red]{len(errors)} error(s)[/red]")
|
|
40
|
+
if warnings:
|
|
41
|
+
parts.append(f"[yellow]{len(warnings)} warning(s)[/yellow]")
|
|
42
|
+
console.print(" ".join(parts))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_json(findings: list[Finding]) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"ok": not any(f.severity is Severity.ERROR for f in findings),
|
|
48
|
+
"errors": sum(1 for f in findings if f.severity is Severity.ERROR),
|
|
49
|
+
"warnings": sum(1 for f in findings if f.severity is Severity.WARNING),
|
|
50
|
+
"findings": [
|
|
51
|
+
{"severity": f.severity.value, "key": f.key, "line": f.line_no, "message": f.message}
|
|
52
|
+
for f in findings
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_json(findings: list[Finding]) -> str:
|
|
58
|
+
return json.dumps(build_json(findings), indent=2)
|
envcontract/schema.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Load and validate the .env.schema contract itself.
|
|
2
|
+
|
|
3
|
+
The schema is YAML. It describes variables and their rules — never values.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
13
|
+
|
|
14
|
+
VarType = Literal["string", "int", "float", "bool", "url", "email", "enum"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaError(Exception):
|
|
18
|
+
"""Raised when a .env.schema file is missing or malformed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VariableRule(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
type: VarType = "string"
|
|
25
|
+
required: bool = True
|
|
26
|
+
secret: bool = False
|
|
27
|
+
default: Optional[str] = None
|
|
28
|
+
pattern: Optional[str] = None
|
|
29
|
+
min: Optional[float] = None
|
|
30
|
+
max: Optional[float] = None
|
|
31
|
+
values: Optional[list[str]] = None
|
|
32
|
+
description: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def _check_enum(self) -> "VariableRule":
|
|
36
|
+
if self.type == "enum" and not self.values:
|
|
37
|
+
raise ValueError("enum type requires a non-empty 'values' list")
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EnvSchema(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
version: int = 1
|
|
45
|
+
variables: dict[str, VariableRule] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_text(cls, text: str) -> "EnvSchema":
|
|
49
|
+
try:
|
|
50
|
+
data = yaml.safe_load(text) or {}
|
|
51
|
+
except yaml.YAMLError as exc:
|
|
52
|
+
raise SchemaError(f"invalid YAML in schema: {exc}") from exc
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise SchemaError("schema root must be a mapping with a 'variables' key")
|
|
55
|
+
try:
|
|
56
|
+
return cls.model_validate(data)
|
|
57
|
+
except ValidationError as exc:
|
|
58
|
+
raise SchemaError(_format_validation_error(exc)) from exc
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_file(cls, path: str | Path) -> "EnvSchema":
|
|
62
|
+
p = Path(path)
|
|
63
|
+
if not p.exists():
|
|
64
|
+
raise SchemaError(
|
|
65
|
+
f"schema file not found: {p}. Run `envcontract init` to create one from your .env."
|
|
66
|
+
)
|
|
67
|
+
return cls.from_text(p.read_text(encoding="utf-8"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _format_validation_error(exc: ValidationError) -> str:
|
|
71
|
+
lines = ["schema is invalid:"]
|
|
72
|
+
for err in exc.errors():
|
|
73
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
74
|
+
lines.append(f" - {loc}: {err['msg']}")
|
|
75
|
+
return "\n".join(lines)
|
envcontract/secrets.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Heuristics for identifying secret-ish keys and safely masking values.
|
|
2
|
+
|
|
3
|
+
Used by `init` (auto-flag secrets), `guard` (detect committed secrets), and
|
|
4
|
+
`report` (never print a secret value).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_SECRET_KEY_PATTERN = re.compile(
|
|
12
|
+
r"(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|API[_-]?KEY|ACCESS[_-]?KEY|"
|
|
13
|
+
r"CREDENTIAL|AUTH|CERT|SIGNING|_KEY$|^KEY$)",
|
|
14
|
+
re.IGNORECASE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def looks_like_secret_key(key: str) -> bool:
|
|
19
|
+
"""True if a variable name suggests it holds a secret."""
|
|
20
|
+
return bool(_SECRET_KEY_PATTERN.search(key))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mask(value: str) -> str:
|
|
24
|
+
"""Mask a secret value so it never appears in full in any output."""
|
|
25
|
+
if value == "":
|
|
26
|
+
return "(empty)"
|
|
27
|
+
if len(value) <= 4:
|
|
28
|
+
return "****"
|
|
29
|
+
return f"{value[:2]}{'*' * 6}{value[-2:]}"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Validate a parsed .env against an EnvSchema and produce findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from .parser import ParsedEnv
|
|
10
|
+
from .schema import EnvSchema, VariableRule
|
|
11
|
+
|
|
12
|
+
_URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s]+$")
|
|
13
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
14
|
+
_BOOL_VALUES = {"true", "false", "1", "0", "yes", "no", "on", "off"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Severity(str, Enum):
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
WARNING = "warning"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fmt(n: float) -> str:
|
|
23
|
+
"""Render 65535.0 as '65535' but keep 3.5 as '3.5'."""
|
|
24
|
+
return str(int(n)) if float(n).is_integer() else str(n)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Finding:
|
|
29
|
+
severity: Severity
|
|
30
|
+
key: str
|
|
31
|
+
message: str
|
|
32
|
+
line_no: int | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_type(value: str, rule: VariableRule) -> str | None:
|
|
36
|
+
"""Return an error message if the value doesn't match the declared type."""
|
|
37
|
+
t = rule.type
|
|
38
|
+
if t == "int":
|
|
39
|
+
try:
|
|
40
|
+
int(value)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return f"expected an integer, got '{value}'"
|
|
43
|
+
elif t == "float":
|
|
44
|
+
try:
|
|
45
|
+
float(value)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return f"expected a number, got '{value}'"
|
|
48
|
+
elif t == "bool":
|
|
49
|
+
if value.lower() not in _BOOL_VALUES:
|
|
50
|
+
return f"expected a boolean (true/false), got '{value}'"
|
|
51
|
+
elif t == "url":
|
|
52
|
+
if not _URL_RE.match(value):
|
|
53
|
+
return f"expected a URL (scheme://...), got '{value}'"
|
|
54
|
+
elif t == "email":
|
|
55
|
+
if not _EMAIL_RE.match(value):
|
|
56
|
+
return f"expected an email address, got '{value}'"
|
|
57
|
+
elif t == "enum":
|
|
58
|
+
if rule.values and value not in rule.values:
|
|
59
|
+
return f"must be one of {rule.values}, got '{value}'"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_rules(value: str, rule: VariableRule) -> list[str]:
|
|
64
|
+
msgs: list[str] = []
|
|
65
|
+
if rule.pattern is not None and not re.search(rule.pattern, value):
|
|
66
|
+
msgs.append(f"does not match required pattern /{rule.pattern}/")
|
|
67
|
+
if rule.min is not None or rule.max is not None:
|
|
68
|
+
if rule.type in {"int", "float"}:
|
|
69
|
+
try:
|
|
70
|
+
num = float(value)
|
|
71
|
+
if rule.min is not None and num < rule.min:
|
|
72
|
+
msgs.append(f"must be >= {_fmt(rule.min)}")
|
|
73
|
+
if rule.max is not None and num > rule.max:
|
|
74
|
+
msgs.append(f"must be <= {_fmt(rule.max)}")
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass # type error already reported
|
|
77
|
+
else:
|
|
78
|
+
length = len(value)
|
|
79
|
+
if rule.min is not None and length < rule.min:
|
|
80
|
+
msgs.append(f"length must be >= {_fmt(rule.min)}")
|
|
81
|
+
if rule.max is not None and length > rule.max:
|
|
82
|
+
msgs.append(f"length must be <= {_fmt(rule.max)}")
|
|
83
|
+
return msgs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate(parsed: ParsedEnv, schema: EnvSchema) -> list[Finding]:
|
|
87
|
+
findings: list[Finding] = []
|
|
88
|
+
env = parsed.as_dict()
|
|
89
|
+
line_of = {e.key: e.line_no for e in parsed.entries}
|
|
90
|
+
|
|
91
|
+
for err in parsed.errors:
|
|
92
|
+
findings.append(Finding(Severity.WARNING, "(parse)", err.message, err.line_no))
|
|
93
|
+
for dup in parsed.duplicate_keys:
|
|
94
|
+
findings.append(
|
|
95
|
+
Finding(Severity.WARNING, dup, "defined more than once (last value wins)", line_of.get(dup))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for key, rule in schema.variables.items():
|
|
99
|
+
present = key in env
|
|
100
|
+
if not present:
|
|
101
|
+
if rule.required and rule.default is None:
|
|
102
|
+
findings.append(Finding(Severity.ERROR, key, "required but missing"))
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
value = env[key]
|
|
106
|
+
if value == "" and rule.required:
|
|
107
|
+
findings.append(Finding(Severity.ERROR, key, "required but empty", line_of.get(key)))
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
type_err = _check_type(value, rule)
|
|
111
|
+
if type_err:
|
|
112
|
+
findings.append(Finding(Severity.ERROR, key, type_err, line_of.get(key)))
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
for msg in _check_rules(value, rule):
|
|
116
|
+
findings.append(Finding(Severity.ERROR, key, msg, line_of.get(key)))
|
|
117
|
+
|
|
118
|
+
for key in env:
|
|
119
|
+
if key not in schema.variables:
|
|
120
|
+
findings.append(
|
|
121
|
+
Finding(Severity.WARNING, key, "present in .env but not declared in schema", line_of.get(key))
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return findings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def has_errors(findings: list[Finding]) -> bool:
|
|
128
|
+
return any(f.severity is Severity.ERROR for f in findings)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envcontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The contract for your .env — validate it, catch team drift, and never commit a secret. 100% local.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hamzamansoorch/envcontract
|
|
6
|
+
Project-URL: Issues, https://github.com/hamzamansoorch/envcontract/issues
|
|
7
|
+
Author: Hamza Mansoor
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,dotenv,env,environment-variables,pre-commit,secrets,validation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: click>=8.1
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# envcontract
|
|
29
|
+
|
|
30
|
+
**The contract for your `.env`.** Validate it, catch team drift, and never commit a secret — **100% local, your values never leave your machine.**
|
|
31
|
+
|
|
32
|
+
> Status: early development (v0.1.0). Built in the open.
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
Teammates add an env var and forget to tell anyone. Secrets get committed by accident. `.env.example` drifts out of sync and was never a real schema. `envcontract` fixes this with one committed contract — `.env.schema` — that lists your variables and their rules, but **never their secret values**.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pipx install envcontract # recommended (isolated)
|
|
42
|
+
# or
|
|
43
|
+
pip install envcontract
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
|---------|--------------|
|
|
50
|
+
| `envcontract init` | Generate a `.env.schema` from your existing `.env` (values stripped). |
|
|
51
|
+
| `envcontract check` | Validate your `.env` against the schema: missing keys, wrong types, failed rules. |
|
|
52
|
+
| `envcontract diff` | Show what your local `.env` has vs. the schema (catches team drift). |
|
|
53
|
+
| `envcontract guard` | Pre-commit hook that blocks committing real values for secret keys. |
|
|
54
|
+
|
|
55
|
+
## Privacy promise
|
|
56
|
+
|
|
57
|
+
`envcontract` makes **zero network calls** and has **no telemetry**. It reads files on your machine and prints to your terminal. Nothing else. This is enforced by a test that fails if any network socket is opened.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
envcontract/__init__.py,sha256=2MeIU-GYBymlHiyof_VKUItQgY-jy6yT6Qlg8thGejM,123
|
|
2
|
+
envcontract/cli.py,sha256=4RPiFhMQr74CioZhHqNlDRWfESJCdIR_UVglG_0gJVU,5073
|
|
3
|
+
envcontract/drift.py,sha256=32X6w51JY0rvezzII5A2setNNtzHICN9L-0RmTLKgus,1077
|
|
4
|
+
envcontract/generate.py,sha256=3hS3TIc6bY-mi9z-S4jFZcJHn6_6i7ojRqyXuVvT6-w,1602
|
|
5
|
+
envcontract/guard.py,sha256=2JoHyEzSwEqsAJSThes0x3yOYYu4EWSa_2Fy-VfuNl0,1679
|
|
6
|
+
envcontract/parser.py,sha256=kTIb__990hucdzj2UkDpE2p9Khd6lYt3obtXzehopEk,3423
|
|
7
|
+
envcontract/report.py,sha256=9pWHUttdM2M71EyMwE2rW1_G6dPW3JR-JYVVW3qVq8I,2010
|
|
8
|
+
envcontract/schema.py,sha256=5GVDlza5Vbgbh5aCYC26axgOgTf_5p7cLUCSrdu_PEs,2385
|
|
9
|
+
envcontract/secrets.py,sha256=NAs-qt0sTfdRiqYRmP3uGV3IRUdnUvP1vwS6nyhoLvs,819
|
|
10
|
+
envcontract/validators.py,sha256=Qt__lBI_tSJqKQ-BXlASjlexwnzTcCoP2LK3AXQNk6M,4383
|
|
11
|
+
envcontract-0.1.0.dist-info/METADATA,sha256=MmIAn8eIwkPTxWQGQBArasyfB2dmEcJZapobY8XENwQ,2401
|
|
12
|
+
envcontract-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
envcontract-0.1.0.dist-info/entry_points.txt,sha256=CojvqWYMvr5RuPUGSqurOLcy44Gsi0XsEpGFVHWpBcE,52
|
|
14
|
+
envcontract-0.1.0.dist-info/licenses/LICENSE,sha256=ughdgoKvVQbEroJad69qEZ2CPPHg4LD20Z7jL4YUA9A,1070
|
|
15
|
+
envcontract-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamza Mansoor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|