envcontract 0.1.0__tar.gz

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,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest, windows-latest]
15
+ python-version: ["3.10", "3.11", "3.12"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: pip install -e ".[dev]"
23
+ - name: Lint
24
+ run: ruff check .
25
+ - name: Test
26
+ run: pytest -q
@@ -0,0 +1,17 @@
1
+ # envcontract: never commit real env files
2
+ .env
3
+ .env.*
4
+ !.env.schema
5
+ !.env.example
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ build/
15
+ dist/
16
+ .venv/
17
+ venv/
@@ -0,0 +1,15 @@
1
+ - id: envcontract-guard
2
+ name: envcontract guard (block committing secret values)
3
+ description: Blocks a commit if staged files contain real values for secret keys.
4
+ entry: envcontract guard
5
+ language: python
6
+ pass_filenames: true
7
+ files: '(^|/)\.env($|\.)'
8
+
9
+ - id: envcontract-check
10
+ name: envcontract check (validate .env against schema)
11
+ description: Validates the local .env against .env.schema.
12
+ entry: envcontract check
13
+ language: python
14
+ pass_filenames: false
15
+ always_run: true
@@ -0,0 +1,33 @@
1
+ # Contributing to envcontract
2
+
3
+ Thanks for your interest! envcontract aims to stay small, fast, and **100% local**.
4
+
5
+ ## Core principles (please don't break these)
6
+
7
+ 1. **Zero network.** envcontract must never make a network call. There's a test
8
+ (`tests/test_invariants.py`) that fails if any socket is opened.
9
+ 2. **Never print a secret value.** Output carries keys, line numbers, and
10
+ messages — never raw values for keys marked `secret`.
11
+ 3. **Small surface area.** We are not a secrets manager and not a generic
12
+ secret scanner. Keep the scope tight.
13
+
14
+ ## Dev setup
15
+
16
+ ```bash
17
+ git clone https://github.com/hamzamansoorch/envcontract
18
+ cd envcontract
19
+ pip install -e ".[dev]"
20
+ pytest -q
21
+ ruff check .
22
+ ```
23
+
24
+ ## Adding a new variable type or rule
25
+
26
+ 1. Add the type to `VarType` in `schema.py`.
27
+ 2. Add validation in `validators.py` (`_check_type` / `_check_rules`).
28
+ 3. Add inference (if sensible) in `generate.py`.
29
+ 4. Add tests and update the schema reference in the README.
30
+
31
+ ## Pull requests
32
+
33
+ Keep PRs focused. Include tests. Run `pytest` and `ruff check .` before pushing.
@@ -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.
@@ -0,0 +1,182 @@
1
+ # envcontract — Launch Checklist & Next Steps
2
+
3
+ Status: code complete (v0.1.0), 29 tests passing, wheel + sdist built.
4
+ What's left is **verify locally → publish to GitHub → publish to PyPI → launch.**
5
+
6
+ ---
7
+
8
+ ## Step 1 — Verify it works on your machine (5 min)
9
+
10
+ Open the VS Code integrated terminal in the project root (the folder with `pyproject.toml`).
11
+
12
+ ```bash
13
+ # (recommended) create a clean virtual environment
14
+ python -m venv .venv
15
+ # Windows:
16
+ .venv\Scripts\activate
17
+ # macOS/Linux:
18
+ # source .venv/bin/activate
19
+
20
+ pip install -e ".[dev]"
21
+
22
+ pytest -q # expect: 29 passed
23
+ ruff check . # expect: All checks passed!
24
+ envcontract --help # should list init / check / diff / guard
25
+ ```
26
+
27
+ If `pytest` shows 29 passed, the project transferred intact. ✅
28
+
29
+ ### Smoke-test the actual commands
30
+
31
+ ```bash
32
+ mkdir demo && cd demo
33
+ printf 'DATABASE_URL=postgres://localhost/db\nPORT=8080\nSTRIPE_KEY=sk_live_abc123\n' > .env
34
+
35
+ envcontract init # creates .env.schema (values stripped, STRIPE_KEY flagged secret)
36
+ type .env.schema # Windows (use `cat` on macOS/Linux)
37
+ envcontract check # should pass
38
+ envcontract guard .env # should BLOCK (real secret in .env)
39
+ cd ..
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Step 2 — Fix the placeholders (2 min)
45
+
46
+ Before publishing, replace these with your real details:
47
+
48
+ - **`pyproject.toml`** → `Homepage` / `Issues` URLs currently use `github.com/hamza/envcontract`.
49
+ - **`README.md`** → the pre-commit example uses `https://github.com/hamzamansoorch/envcontract`.
50
+ - **`LICENSE`** → confirm the copyright name/year.
51
+ - **`CONTRIBUTING.md`** → the clone URL uses `<you>`.
52
+
53
+
54
+ ---
55
+
56
+ ## Step 3 — Confirm the name is still free (2 min)
57
+
58
+ ```bash
59
+ pip index versions envcontract # "not found" = available
60
+ ```
61
+
62
+ Also check `https://pypi.org/project/envcontract/` and `https://github.com/hamzamansoorch/envcontract` in a browser.
63
+ If taken, pick a fallback (`envguard`, `envschema`, `dotcheck`) and rename in `pyproject.toml` (`name =`) and the `[project.scripts]` entry.
64
+
65
+ ---
66
+
67
+ ## Step 4 — Put it on GitHub (5 min)
68
+
69
+ ```bash
70
+ git init
71
+ git add .
72
+ git commit -m "envcontract v0.1.0 — validate .env, catch drift, guard secrets"
73
+ git branch -M main
74
+ # create an empty repo named 'envcontract' on github.com first, then:
75
+ git remote add origin https://github.com/hamzamansoorch/envcontract.git
76
+ git push -u origin main
77
+ ```
78
+
79
+ Your `.gitignore` already excludes real `.env` files and build artifacts. Double-check no `.env` got committed: `git ls-files | findstr .env` (should only show `.env.schema.example`).
80
+
81
+ After pushing, the GitHub Actions CI (`.github/workflows/ci.yml`) runs automatically on Linux/macOS/Windows across Python 3.10–3.12.
82
+
83
+ ---
84
+
85
+ ## Step 5 — Publish to PyPI (10 min)
86
+
87
+ First test on **TestPyPI** so you don't waste the real name on a mistake.
88
+
89
+ ```bash
90
+ pip install build twine
91
+ python -m build # rebuilds dist/ (wheel + sdist)
92
+
93
+ # 5a. Test upload
94
+ twine upload --repository testpypi dist/*
95
+ pip install --index-url https://test.pypi.org/simple/ envcontract # try it in a fresh venv
96
+
97
+ # 5b. Real upload (once happy)
98
+ twine upload dist/*
99
+ ```
100
+
101
+ You'll need a PyPI account + an API token (Account settings → API tokens). Use `__token__` as the username and the token as the password.
102
+
103
+ ---
104
+
105
+ ## Step 6 — Tag a release (2 min)
106
+
107
+ ```bash
108
+ git tag v0.1.0
109
+ git push origin v0.1.0
110
+ ```
111
+
112
+ Then on GitHub: Releases → Draft a new release → pick the tag → paste highlights from the README.
113
+ This is also the `rev:` users reference in the pre-commit example.
114
+
115
+ ---
116
+
117
+ ## How END USERS will install & use it (this is your "it works" demo)
118
+
119
+ Once it's on PyPI, anyone can do:
120
+
121
+ ```bash
122
+ pipx install envcontract # or: pip install envcontract
123
+
124
+ cd their-project
125
+ envcontract init # generate .env.schema from their .env
126
+ git add .env.schema # commit the contract (no secrets in it)
127
+
128
+ envcontract check # validate their .env → exit 1 if broken
129
+ envcontract diff # see what their .env is missing vs the schema
130
+ ```
131
+
132
+ **As a pre-commit hook** (their `.pre-commit-config.yaml`):
133
+
134
+ ```yaml
135
+ repos:
136
+ - repo: https://github.com/<you>/envcontract
137
+ rev: v0.1.0
138
+ hooks:
139
+ - id: envcontract-guard # blocks committing real secret values
140
+ ```
141
+
142
+ **In CI** (their GitHub Actions):
143
+
144
+ ```yaml
145
+ - run: pip install envcontract
146
+ - run: envcontract check --json # fails the build if .env is invalid
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Step 7 — Launch & get stars
152
+
153
+ - **Show HN**: title like "Show HN: envcontract – a local-only contract for your .env (validate, drift, secret guard)". Lead with the privacy promise.
154
+ - **r/Python and r/devops**: short post, link the repo, ask for feedback.
155
+ - **Add a demo GIF** to the top of the README (use asciinema + agg, or a screen recorder). A visual demo dramatically increases stars.
156
+ - Pin good first issues so contributors have an entry point.
157
+
158
+ ---
159
+
160
+ ## What's left to BUILD (optional, post-launch)
161
+
162
+ - Demo GIF/asciinema in README (high impact, do this before Show HN).
163
+ - `.vscode/settings.json` for contributors (interpreter, pytest, ruff).
164
+ - Multi-environment support (`.env.development`, `.env.production` against one schema).
165
+ - VS Code extension for inline schema validation.
166
+ - `envcontract sync` to interactively update a local `.env` to match schema additions.
167
+
168
+ ---
169
+
170
+ ## Quick status
171
+
172
+ | Item | State |
173
+ |------|-------|
174
+ | 4 commands (init/check/diff/guard) | ✅ done |
175
+ | 29 tests + lint | ✅ passing |
176
+ | Privacy invariants (no-network, no secret printing) | ✅ enforced by tests |
177
+ | README / CONTRIBUTING / LICENSE / CI / pre-commit | ✅ done |
178
+ | Wheel + sdist built | ✅ in `dist/` |
179
+ | Placeholders replaced | ⬜ you |
180
+ | GitHub repo | ⬜ you |
181
+ | PyPI publish | ⬜ you |
182
+ | Demo GIF + launch posts | ⬜ optional |
@@ -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,34 @@
1
+ # envcontract
2
+
3
+ **The contract for your `.env`.** Validate it, catch team drift, and never commit a secret — **100% local, your values never leave your machine.**
4
+
5
+ > Status: early development (v0.1.0). Built in the open.
6
+
7
+ ## Why
8
+
9
+ 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**.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pipx install envcontract # recommended (isolated)
15
+ # or
16
+ pip install envcontract
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ | Command | What it does |
22
+ |---------|--------------|
23
+ | `envcontract init` | Generate a `.env.schema` from your existing `.env` (values stripped). |
24
+ | `envcontract check` | Validate your `.env` against the schema: missing keys, wrong types, failed rules. |
25
+ | `envcontract diff` | Show what your local `.env` has vs. the schema (catches team drift). |
26
+ | `envcontract guard` | Pre-commit hook that blocks committing real values for secret keys. |
27
+
28
+ ## Privacy promise
29
+
30
+ `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.
31
+
32
+ ## License
33
+
34
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "envcontract"
7
+ version = "0.1.0"
8
+ description = "The contract for your .env — validate it, catch team drift, and never commit a secret. 100% local."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Hamza Mansoor" }]
13
+ keywords = ["dotenv", "env", "environment-variables", "validation", "secrets", "cli", "pre-commit"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+ dependencies = [
23
+ "click>=8.1",
24
+ "rich>=13.0",
25
+ "pydantic>=2.0",
26
+ "PyYAML>=6.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7.0", "ruff>=0.4", "mypy>=1.0"]
31
+
32
+ [project.scripts]
33
+ envcontract = "envcontract.cli:cli"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/hamzamansoorch/envcontract"
37
+ Issues = "https://github.com/hamzamansoorch/envcontract/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/envcontract"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py310"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
@@ -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"
@@ -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()
@@ -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"