Offline environment contract diff for code, templates and Compose
env-contract finds drift between the environment variables a Python project
reads (in code), documents (in .env.example) and wires into Docker
Compose — without ever opening a real .env file or printing a value.
Requires Python 3.10 or newer. The only runtime dependency is PyYAML.
From a checkout:
pip install .From a GitHub Release wheel (replace the version with the release you want):
pip install https://github.com/N-Combinator/env-contract/releases/download/v0.1.0/env_contract-0.1.0-py3-none-any.whlenv-contract check path/to/project --json{
"missing_in_template": ["IMAGE_TAG", "LOG_LEVEL"],
"unused_in_code": ["UNUSED_TEMPLATE_VAR"],
"undeclared_in_compose": ["LEGACY_MODE", "PORT", "SENTRY_DSN", "UNUSED_TEMPLATE_VAR"],
"warnings": [
"app/settings.py:21: dynamic name in os.getenv() ignored",
".env.example:13: not a KEY=value line, ignored"
]
}Without --json the same report is printed as readable text.
env-contract template path/to/project > .env.example.new# Environment variables for project (generated by env-contract; values left blank)
DATABASE_URL=
DEBUG=
LOG_LEVEL=
Every name found anywhere (code, .env.example, Compose) is printed once,
sorted, with no value. Warnings go to stderr. Nothing is written to disk.
With A = names read by Python code, B = keys in .env.example, C = names in the
Compose file:
| key | value |
|---|---|
missing_in_template |
sorted((A ∪ C) − B) |
unused_in_code |
sorted(B − A) |
undeclared_in_compose |
sorted((A ∪ B) − C), or null when the project has no Compose file |
warnings |
list of strings, path:line: message where a line is known |
Exit codes:
| code | meaning |
|---|---|
| 0 | all three lists are empty (null counts as empty; warnings do not affect the code) |
| 1 | at least one list is non-empty |
| 2 | usage error, Python SyntaxError, invalid YAML or a file that cannot be read (reported on stderr as path:line: ... or path: ...) |
A — Python code. Every *.py file under the project, recursively, skipping
.venv, venv, node_modules and .git directories and any directory that
contains a pyvenv.cfg (a virtual environment, whatever it is named — including
one called .env). Files are parsed with
ast (never imported or executed). String-literal names are collected from:
os.getenv("X"),os.getenv("X", default)os.environ.get("X"),os.environ.get("X", default)os.environ["X"](reads only —os.environ["X"] = ...anddel os.environ["X"]set up a child process environment and are not counted)"X" in os.environ,"X" not in os.environ
import os as o, from os import environ, getenv and their aliases are
followed. A non-literal name (os.getenv(name), f-strings, concatenation) is
ignored and reported in warnings. A *.py symlink whose target is missing (or
that loops) is skipped with a warning, and so is a file nested too deeply for
the parser or the tree walk. A directory that cannot be listed is reported in
warnings with its path, since the names in it are unknown. Syntax warnings in
the scanned code (such as invalid escape sequences) are not printed.
B — .env.example at the project root: KEY=value, export KEY=value and
bare KEY lines, read as UTF-8 (a leading byte order mark is ignored); blank
lines and # comments are skipped. Values are discarded.
A missing file is a warning.
C — Compose file at the project root: the first of compose.yaml,
compose.yml, docker-compose.yaml, docker-compose.yml. Names come from
services.*.environment in both list (- KEY / - KEY=value) and mapping
(KEY: value) form, plus interpolations anywhere in values: ${NAME},
${NAME:-default} and the other ${NAME...} modifiers, nested defaults, and
bare $NAME; $$ escapes are respected. YAML anchors, merge keys (<<) and
Compose tags such as !reset / !override are supported. Parsing uses PyYAML.
A missing Compose file is a warning and makes undeclared_in_compose null.
No file named .env or .env.* (other than .env.example) is ever opened —
including a .env.example, Compose or Python file that is a symlink to one.
Values from .env.example and Compose are never printed. The test suite plants
.env files with a sentinel secret and records every open (via an audit hook
and a patched open) to enforce this.
- rogerchappel/envsentinel — a local-first environment contract auditor (examples, docs, CI, Compose, code references) for TypeScript projects.
- EnvSentinel — validates
.envfiles against a schema you author. - dotenv-linter — lints the syntax and style of
.envfiles. - envcheck — checks a
.envfile against.env.example(several projects share the name).
The difference: env-contract derives the contract from the code itself using
Python's ast, compares it with the template and Compose, needs no schema, and
installs with pip on top of the standard library plus PyYAML. It never reads
real .env files.
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest -qReleases: bump version in pyproject.toml, then push a matching vX.Y.Z tag.
The release workflow runs the tests, checks the tag against the version, and
attaches the sdist and wheel to a GitHub Release.