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.
- envcontract-0.1.0/.github/workflows/ci.yml +26 -0
- envcontract-0.1.0/.gitignore +17 -0
- envcontract-0.1.0/.pre-commit-hooks.yaml +15 -0
- envcontract-0.1.0/CONTRIBUTING.md +33 -0
- envcontract-0.1.0/LICENSE +21 -0
- envcontract-0.1.0/NEXT_STEPS.md +182 -0
- envcontract-0.1.0/PKG-INFO +61 -0
- envcontract-0.1.0/README.md +34 -0
- envcontract-0.1.0/pyproject.toml +47 -0
- envcontract-0.1.0/src/envcontract/__init__.py +3 -0
- envcontract-0.1.0/src/envcontract/cli.py +138 -0
- envcontract-0.1.0/src/envcontract/drift.py +33 -0
- envcontract-0.1.0/src/envcontract/generate.py +53 -0
- envcontract-0.1.0/src/envcontract/guard.py +54 -0
- envcontract-0.1.0/src/envcontract/parser.py +115 -0
- envcontract-0.1.0/src/envcontract/report.py +58 -0
- envcontract-0.1.0/src/envcontract/schema.py +75 -0
- envcontract-0.1.0/src/envcontract/secrets.py +29 -0
- envcontract-0.1.0/src/envcontract/validators.py +128 -0
- envcontract-0.1.0/tests/test_cli.py +18 -0
- envcontract-0.1.0/tests/test_generate_drift_guard.py +87 -0
- envcontract-0.1.0/tests/test_invariants.py +42 -0
- envcontract-0.1.0/tests/test_parser.py +48 -0
- envcontract-0.1.0/tests/test_schema.py +52 -0
- envcontract-0.1.0/tests/test_validators.py +70 -0
|
@@ -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,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,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"
|