A tiny, generic Python codegen orchestrator.
Project description
evergen
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:4660ab1ff310887b>> — 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, useuv tool run --from /path/to/evergen evergen …(or--from ../..from insideexamples/).
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 noBodyHash<<…>>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.2.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
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_PATTERNis absolute, it is used as an absolute path. - If
OUT_PATTERNis relative and the matchingINPUT_PATTERNcontains**, the output path is resolved relative to the matched generator file's directory. For example,src/**/{}.eg.pywith--output {}__out.pymapssrc/pkg/user.eg.pytosrc/pkg/user__out.py. - Otherwise, a relative
OUT_PATTERNis 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:digest — the hash algorithm's name, a colon, and the first 16
hex characters of that algorithm's digest over the generated body, excluding
the header line (today: sha256:…). 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
16-hex digest is accidental-edit tamper-evidence, not adversarial collision
resistance.
Self-describing hash algorithm. The token carries its algorithm —
BodyHash<<sha256:…>> — so the algorithm 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) and verifies
with whatever hashlib algorithm the token names; a token naming an algorithm
this Python does not provide 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file evergen-0.2.0.tar.gz.
File metadata
- Download URL: evergen-0.2.0.tar.gz
- Upload date:
- Size: 29.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
990f5f884e435fb85a071675d230442bf667209f36e8d1c3f57b3d226bf8c564
|
|
| MD5 |
5a5da90b507157986f6fd0ec6762b135
|
|
| BLAKE2b-256 |
69ccb20542165054fe57b34e488b8d31885d41d32a5ca93f4bfdc621c5394c1a
|
Provenance
The following attestation bundles were made for evergen-0.2.0.tar.gz:
Publisher:
publish.yml on Elijas/evergen
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
evergen-0.2.0.tar.gz -
Subject digest:
990f5f884e435fb85a071675d230442bf667209f36e8d1c3f57b3d226bf8c564 - Sigstore transparency entry: 2088521044
- Sigstore integration time:
-
Permalink:
Elijas/evergen@e2db76e9e2f1fd70371220850035c0d96ea854fc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Elijas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e2db76e9e2f1fd70371220850035c0d96ea854fc -
Trigger Event:
push
-
Statement type:
File details
Details for the file evergen-0.2.0-py3-none-any.whl.
File metadata
- Download URL: evergen-0.2.0-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3d2d2c700d6171bbae8b974adf708672a354c05a734e0cedc87612f76a6f9efb
|
|
| MD5 |
6a86f177b14da3a3259c1db9bbbe1f8f
|
|
| BLAKE2b-256 |
aa4f02b8d6cf70a5117d887483042213679089f49877e95bf9f6761628c1ac8d
|
Provenance
The following attestation bundles were made for evergen-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on Elijas/evergen
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
evergen-0.2.0-py3-none-any.whl -
Subject digest:
3d2d2c700d6171bbae8b974adf708672a354c05a734e0cedc87612f76a6f9efb - Sigstore transparency entry: 2088521319
- Sigstore integration time:
-
Permalink:
Elijas/evergen@e2db76e9e2f1fd70371220850035c0d96ea854fc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/Elijas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e2db76e9e2f1fd70371220850035c0d96ea854fc -
Trigger Event:
push
-
Statement type: