Skip to content

About

Offline environment contract diff for code, templates and Compose

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

env-contract

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.

Install

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.whl

Usage

check

env-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.

template

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.

Output

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: ...)

What is scanned

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"] = ... and del 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.

Secrets are never read

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.

Similar tools

  • rogerchappel/envsentinel — a local-first environment contract auditor (examples, docs, CI, Compose, code references) for TypeScript projects.
  • EnvSentinel — validates .env files against a schema you author.
  • dotenv-linter — lints the syntax and style of .env files.
  • envcheck — checks a .env file 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.

Development

python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest -q

Releases: 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.

About

Offline environment contract diff for code, templates and Compose

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages