Skip to main content

A tiny, generic Python codegen orchestrator.

Project description

evergen

PyPI CI Python License: MIT

Committed generated code has a failure mode: someone hand-edits the generated output, then the next regeneration silently destroys those edits — or the generator changed and nobody noticed the committed output is now stale. evergen makes regeneration safe by mechanism instead of by discipline. A generator is any Python file that exposes gen() -> str; evergen executes it, writes the output with a one-line header carrying a hash of the body, and uses that header to tell a clean generated file from a stale one from one a human edited. It refuses to overwrite hand edits, and its --check mode fails CI when a committed output has drifted.

Use evergen when / do not use evergen when

Use evergen when you have small, repo-local generated files that should be committed, reviewed, and regenerated safely: constants, schemas, typed wrappers, repetitive model classes, test fixtures, or language-specific files rendered from project data.

Do not use evergen when you need a scaffolding engine, an interactive project copier, a long-running build system, dependency tracking, template discovery, or sandboxed execution. Generators are trusted Python code (see Trust model).

Why not just write a script?

Instead of… evergen's difference
A hand-rolled regenerate script A script clobbers on rerun; it does not know the committed output was hand-edited since, so those edits vanish. evergen classifies the target first and refuses dirty files.
Make Make schedules rebuilds from mtimes; it neither detects a hand-edited generated file nor is a codegen framework. evergen is drift detection on committed outputs, not build scheduling.
cog cog embeds generator code inside the output between markers. evergen keeps the generator in a separate .eg.py; the output stays clean generated code with one header line.
jinja2-cli Renders one template from a data file — no state tracking, no drift detection, and Jinja is mandatory. evergen is template-engine agnostic and tracks clean/stale/dirty.
Copier / Cookiecutter Project scaffolders for one-time (or update-driven) whole-project generation with prompts. evergen is for small repo-local files you regenerate continuously and keep committed.

evergen's niche is narrow on purpose: a tiny generic runner over committed outputs, with deterministic file mapping, signed dirty/stale detection, safe refusals, zero dependencies, and no opinion about your template engine.

Install

evergen needs Python >= 3.10 and has zero runtime dependencies.

uvx evergen --help                 # run without installing (uv)
uv tool install evergen            # install as a uv tool
pipx install evergen               # or pipx
python -m pip install evergen      # or plain pip, into a venv

Quick start

Works from an empty directory with only Python and uv — no clone required.

mkdir evergen-demo && cd evergen-demo

cat > hello.eg.py <<'PY'
def gen():
    return 'print("hello world")\n'
PY

uvx evergen --output '{}.py' '{}.eg.py'
WROTE hello.py <- hello.eg.py

The input pattern's {} captures hello (the .eg generator suffix is stripped), and that capture fills the {} in --output. hello.py is a committed, runnable file:

# @generated by evergen from hello.eg.py — BodyHash<<sha256.b32[:8]:IZQKWH7T>> — do not hand-edit
print("hello world")
python hello.py
hello world

Now ask evergen whether the committed output is still current — this is the payoff. First, clean:

uvx evergen --check --output '{}.py' '{}.eg.py'
OK hello.py <- hello.eg.py

Then hand-edit the generated file, as a human eventually will, and check again:

echo 'print("oops, a hand edit")' >> hello.py
uvx evergen --check --output '{}.py' '{}.eg.py'
DIRTY hello.py <- hello.eg.py: hand-edited; keep your edits by removing the header and deleting the generator, or discard them with --overwrite

--check exits nonzero, so this is what fails your commit or CI instead of the next regeneration silently destroying the edit.

Running the repo examples / developing evergen itself. The examples in examples/ run the same way with the published package: uvx evergen --output '{}.py' '{}.eg.py'. To run against a local checkout instead, use uv tool run --from /path/to/evergen evergen … (or --from ../.. from inside examples/).

The three states

The BodyHash<<…>> token is the mechanism. When evergen is about to write a target that already exists, it verifies the stored hash against the file's body and acts on the result:

Existing target State Without --overwrite With --overwrite
Header present, hash matches body clean overwrite overwrite
No BodyHash<<…>> token unmanaged refuse: not generated by evergen; use --overwrite to replace replace
Header present, hash does not match dirty (hand-edited) refuse: hand-edited; keep your edits by removing the header and deleting the generator, or discard them with --overwrite replace

A missing target is simply written. Refusals exit nonzero after reporting every target, so no accidental hand-edit is silently destroyed.

The BodyHash header is an accidental-edit guard, not a cryptographic signature. It is unauthenticated: the token is a truncated hash of the body, so any tool, teammate, or generator that can write the file can also recompute a matching header and present a hand edit as clean. It protects against the everyday mistake — a human edits generated output and forgets — not against a party deliberately forging the marker. Treat it as tamper- evidence for accidents, not tamper-proofing.

--check and pre-commit

--check writes nothing and exits nonzero if any target is not clean and current:

  • MISSING: the output file does not exist.
  • UNMANAGED: the output has no BodyHash<<…>> token.
  • DIRTY: the output has a token but its signed hash no longer matches its body.
  • STALE: the hash is valid, but rerunning the generator would produce a different body after newline normalization.

DIRTY vs STALE is the useful distinction in CI: dirty means a human edited generated output (their edits are at risk); stale means the generator changed and someone forgot to rerun evergen.

--check executes generator code. It is not inert linting: to tell STALE from OK it imports each generator module and calls gen(), with whatever side effects that code has. MISSING, UNMANAGED, and DIRTY are decided from the file on disk without running the generator, but any target that would otherwise be OK/STALE runs it. Same trust model as the rest of evergen: only run --check on repositories you already trust, and remember that a pass_filenames: false pre-commit hook runs every matching generator on each commit.

Pre-commit hook (pinned to a released version):

repos:
  - repo: local
    hooks:
      - id: evergen-check
        name: evergen check
        entry: uvx evergen@0.3.0 --check --output '{}.py' '{}.eg.py'
        language: system
        pass_filenames: false

Graduation

When a generated file has diverged enough that you want to hand-own it, graduate it: delete the header line from the output and delete the generator file. The output is now ordinary source code; evergen has no further claim on it. This is the intended exit ramp — the dirty-state refusal message points here.

Bring your own templates (Jinja2 example)

evergen does not depend on Jinja2 and never will. If you want templates, make Jinja2 your project's dependency and call it from your generator — evergen only sees gen() -> str. examples/jinja_family/ is the full runnable version; the shape:

templates/
  model.j2.py
models.eg.py

templates/model.j2.py:

class {{ name }}:
    table = "{{ table }}"

models.eg.py:

from pathlib import Path
from jinja2 import Template

HERE = Path(__file__).parent

ROWS = [
    {"name": "User", "table": "users"},
    {"name": "Invoice", "table": "invoices"},
]

def render(template_name, **data):
    text = (HERE / "templates" / template_name).read_text()
    return Template(text).render(**data)

def gen():
    return "\n\n".join(render("model.j2.py", **row) for row in ROWS) + "\n"

Run with the dependency supplied at the call site:

uvx --with jinja2 evergen --output '{}.py' '{}.eg.py'
WROTE models.py <- models.eg.py

The generated models.py — one signed, committed file rendered from the template and both rows:

# @generated by evergen from models.eg.py — BodyHash<<sha256.b32[:8]:Q6BKAE3R>> — do not hand-edit
class User:
    table = "users"

class Invoice:
    table = "invoices"

CLI reference

evergen --output OUT_PATTERN [--check] [--overwrite] [--header TEMPLATE] INPUT_PATTERN [INPUT_PATTERN ...]

Input and output patterns

INPUT_PATTERN is a glob containing exactly one {} placeholder. The text matched by {} is the capture and is substituted into OUT_PATTERN, which also must contain exactly one {}. A capture of . or .. is rejected, and a mapping whose resolved output path equals its generator source path is a hard error (evergen will not overwrite a generator with its own output).

As a convenience, an input may also be a plain .py file path with no glob characters and no {}. Its capture is the filename minus the final .py and minus a trailing generator suffix of .eg, .gen, or .generator when present. For example, summary.eg.py maps with capture summary.

Generator files are loaded by file path with importlib spec-from-file machinery, never by package import — dotted filenames such as summary.eg.py work. While a generator executes, its own directory is prepended to sys.path (so sibling modules import normally) and its module object is registered in sys.modules (so decorators and libraries that inspect sys.modules[cls.__module__], such as dataclasses, behave). Both are undone after the generator runs, and sibling modules it imported are evicted from sys.modules too — each generator re-imports its siblings fresh, so two generators with same-named siblings never share state.

Output path resolution:

  • If OUT_PATTERN is absolute, it is used as an absolute path.
  • If OUT_PATTERN is relative and the matching INPUT_PATTERN contains **, the output path is resolved relative to the matched generator file's directory. For example, src/**/{}.eg.py with --output {}__out.py maps src/pkg/user.eg.py to src/pkg/user__out.py.
  • Otherwise, a relative OUT_PATTERN is resolved relative to the current working directory.

Processing order is deterministic: matched generator paths are sorted before execution. If two inputs map to the same output path, evergen exits with a hard error before writing any file.

--header TEMPLATE

The default header line is:

# @generated by evergen from {source} — BodyHash<<{hash}>> — do not hand-edit

{source} is the generator path relative to the output file. {hash} renders as algorithm.encoding[:N]:digest — it reads as the pseudocode it is: the named algorithm's digest over the generated body (excluding the header line), rendered in the named encoding, sliced to its first N characters. Today that is sha256.b32[:8]:… — RFC 4648 base32 (A–Z2–7, no 0/O or 1/l lookalikes), 8 chars = 40 bits; the label says "truncated" out loud rather than implying the full digest. Newlines are normalized to \n before hashing, so CRLF checkouts do not create false dirty reports.

--header TEMPLATE replaces the header line. The template must be one line and must contain {hash}; {source} is optional. Use it to match the comment syntax of non-Python outputs.

Design notes

Determinism law. gen() must be deterministic: the same repository state must produce the same bytes. evergen documents this law but does not enforce it. (Internally evergen also refuses to generate from stale bytecode — it compiles generator source directly instead of going through the __pycache__ machinery.)

Trust model. Generator files are trusted code. evergen loads and executes them with the same trust model as setup.py; only run generators from repositories you trust. --check runs them too (see above).

State precedence. Existing outputs are classified before the generator runs. In write mode, an unmanaged or dirty target without --overwrite is refused without executing its generator. In --check, MISSING/UNMANAGED/ DIRTY are reported from disk alone; the generator runs only to distinguish STALE from OK. An existing target that is not valid UTF-8 is treated as unmanaged.

BodyHash detection invariant. Detection is decoration-independent: the machine-invariant marker is the literal token BodyHash<<…>>, and custom --header templates must place the digest in BodyHash<<{hash}>>. evergen detects the token by scanning the first five lines of an existing file, and hash verification covers everything after the line containing the token. The truncated digest is accidental-edit tamper-evidence, not adversarial collision resistance.

Self-describing hash token. The token carries its algorithm, its digest encoding, and its truncation — BodyHash<<sha256.b32[:8]:…>> — so any of the three can change in a future release without another header-format break. evergen writes SHA-256 (hardware-accelerated, stdlib, more than enough for tiny generated files) as 8 base32 chars (40 bits), and verifies with whatever algorithm, encoding, and slice the token declares — hex tokens verify too. A token naming a scheme this evergen cannot compute is an explicit error, never a silent guess.

Atomic writes. Each output is written to a sibling temporary file and then renamed into place, so a process crash or write exception mid-write cannot truncate an existing output. This is crash safety, not durability: evergen does not fsync, so it makes no promise about power loss. When an existing target is rewritten, its file permissions are preserved.

Symlinked outputs. An output path that is a symlink is resolved before reading and writing, so the generated bytes land on the symlink's target, not on the link. The resolved target is still subject to the clean/dirty/unmanaged classification above.

Path-resolution rule. Relative outputs follow the ** rule above: recursive patterns anchor outputs next to their generators; everything else anchors to the current working directory.

FAQ

Is it safe to run generators I did not write? No. Generators are trusted Python executed like setup.py, and --check runs them too. Only point evergen at repositories you already trust.

Does evergen require my generators to be deterministic? Yes. gen() must return the same bytes for the same repository state. evergen documents this law but does not enforce it; a nondeterministic generator produces spurious STALE reports.

Why commit generated code at all? So it is reviewable in diffs, buildable without the generator toolchain installed, and greppable like any other source. evergen's whole job is to keep those committed files honest — clean, current, and not silently clobbering hand edits.

Can it generate non-Python files? Yes. gen() returns any text; pass --header to render the marker in the target language's comment syntax. The only requirement is that the header render BodyHash<<{hash}>> on one line.

Does evergen track dependencies? No. It maps generator → output and detects drift in the committed output; it does not know what data files or modules your generator reads. If a generator's inputs change without the generator file changing, wire that into your own build/CI — evergen only re-derives from the generator you point it at.

Name

PyPI project: https://pypi.org/project/evergen/

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

evergen-0.3.0.tar.gz (30.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

evergen-0.3.0-py3-none-any.whl (19.2 kB view details)

Uploaded Python 3

File details

Details for the file evergen-0.3.0.tar.gz.

File metadata

  • Download URL: evergen-0.3.0.tar.gz
  • Upload date:
  • Size: 30.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for evergen-0.3.0.tar.gz
Algorithm Hash digest
SHA256 b6adab38bf9551debde525ad6ebb26ce67759f2b8899ec5a851be9707776d7dc
MD5 03a14ff9f3c35d62ab95628aae536636
BLAKE2b-256 6b46ce348068362756217ef584e91e96796f3882fd61424c66b69392a482ac12

See more details on using hashes here.

Provenance

The following attestation bundles were made for evergen-0.3.0.tar.gz:

Publisher: publish.yml on Elijas/evergen

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file evergen-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: evergen-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 19.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for evergen-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4b32eef7878c6b5a2b523b383acaa8e1487d389e69fb10c12c510122c88453a7
MD5 966f060cfc6df82a4841c0015897d481
BLAKE2b-256 92a62cd3ea2d03f49da1f32a3f82a34b6eeb4d7587085d2b2643097a55782ac5

See more details on using hashes here.

Provenance

The following attestation bundles were made for evergen-0.3.0-py3-none-any.whl:

Publisher: publish.yml on Elijas/evergen

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page