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.
@@ -0,0 +1,3 @@
1
+ """envcontract — the contract for your .env. 100% local, your values never leave your machine."""
2
+
3
+ __version__ = "0.1.0"
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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envcontract = envcontract.cli:cli
@@ -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.