Metadata-Version: 2.4
Name: evergen
Version: 0.2.0
Summary: A tiny, generic Python codegen orchestrator.
Project-URL: Homepage, https://github.com/Elijas/evergen
Project-URL: Repository, https://github.com/Elijas/evergen
Project-URL: Issues, https://github.com/Elijas/evergen/issues
Project-URL: Changelog, https://github.com/Elijas/evergen/blob/main/CHANGELOG.md
Author: Elijas
License-Expression: MIT
License-File: LICENSE
Keywords: cli,code-generation,codegen,generator,scaffolding
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# evergen

[![PyPI](https://img.shields.io/pypi/v/evergen.svg)](https://pypi.org/project/evergen/)
[![CI](https://github.com/Elijas/evergen/actions/workflows/ci.yml/badge.svg)](https://github.com/Elijas/evergen/actions/workflows/ci.yml)
[![Python](https://img.shields.io/pypi/pyversions/evergen.svg)](https://pypi.org/project/evergen/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Elijas/evergen/blob/main/LICENSE)

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](#design-notes)).

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

```sh
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.

```sh
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'
```

```text
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:

```python
# @generated by evergen from hello.eg.py — BodyHash<<sha256:4660ab1ff310887b>> — do not hand-edit
print("hello world")
```

```sh
python hello.py
```

```text
hello world
```

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

```sh
uvx evergen --check --output '{}.py' '{}.eg.py'
```

```text
OK hello.py <- hello.eg.py
```

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

```sh
echo 'print("oops, a hand edit")' >> hello.py
uvx evergen --check --output '{}.py' '{}.eg.py'
```

```text
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/`](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):

```yaml
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/`](examples/jinja_family/)
is the full runnable version; the shape:

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

`templates/model.j2.py`:

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

`models.eg.py`:

```python
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:

```sh
uvx --with jinja2 evergen --output '{}.py' '{}.eg.py'
```

```text
WROTE models.py <- models.eg.py
```

## CLI reference

```sh
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:

```text
# @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/>
