Metadata-Version: 2.4
Name: eksp
Version: 0.10.0
Summary: Resolve Jinja-style {{ ... }} references in JSON configurations from layered namespaces.
Project-URL: Repository, https://github.com/gddata/eksp
Author: George Dogaru
License: MIT
License-File: LICENSE
Keywords: cli,config,jinja,json,templating
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Requires-Dist: click>=8.1
Requires-Dist: jinja2>=3.1
Description-Content-Type: text/markdown

# eksp

`eksp` is a small Python CLI that resolves Jinja-style `{{ ... }}` references
inside JSON configurations. You point it at one or more JSON files and optional
`--srcvar` / `--var` string assignments; it merges `--src` inputs,
patches `--srcvar` into that merged document, shallow-merges `--ctxt` into
`CTXT` and patches `--var` there, expands references against those dictionaries (plus
`ENV`), and prints the resolved JSON.

It is intentionally minimal:

- Only Jinja's `{{ ... }}` substitution is part of the contract - no `{% %}`
blocks, loops, filters, or macros are documented.
- Variables are looked up by the part of the expression before the first `.`
or `[`. That part names a top-level namespace dictionary; the rest of the
expression follows Jinja's standard attribute / item access.
- Each string value is re-rendered until two consecutive renders produce the
same text (a fixed-point check), which both expands chained references and
prevents infinite recursion.

## Installation

[uv](https://docs.astral.sh/uv/) is the recommended toolchain.

```bash
git clone <this-repo> eksp
cd eksp
uv sync                      # installs runtime + dev dependencies into .venv
uv run eksp --help           # run from the project venv
```

Install as a global tool:

```bash
uv tool install .
eksp --help
```

Or with plain `pip`:

```bash
pip install .
```

## Sample configs

`eksp-sample` creates a folder (default `./eksp-sample`) with three example JSON
files and a README that shows the exact `eksp` command to run:

```bash
uv run eksp-sample              # or: eksp-sample
cd eksp-sample
# follow README.md inside the folder
```

## Quick start

You can omit the label: each file is still available as `SRC1`, `SRC2`, …
(see Namespaces).

```bash
export DB_HOST=db.example.com
export DATE=2026-05-06

eksp \
  --src cfg1.json \
  --src cfg2.json \
  --var name1=abc \
  --var name2=def \
  --var name3=ghi
```

Or name sources explicitly:

```bash
eksp \
  --src R1=cfg1.json \
  --src R2=cfg2.json \
  --var name1=abc \
  --var name2=def \
  --var name3=ghi
```

Given `cfg1.json`:

```json
{
  "service": "billing",
  "db": { "host": "{{ ENV.DB_HOST }}", "port": 5432 },
  "owner": "{{ CTXT.name1 }}"
}
```

and `cfg2.json`:

```json
{
  "today": "Today is {{ ENV.DATE }}.",
  "rollout": ["{{ CTXT.name3 }}", "{{ CTXT.name2 }}"]
}
```

`eksp` prints:

```json
{
  "service": "billing",
  "db": { "host": "db.example.com", "port": 5432 },
  "owner": "abc",
  "today": "Today is 2026-05-06.",
  "rollout": ["ghi", "def"]
}
```

## CLI reference

```
eksp [OPTIONS]

  --src [LABEL=]PATH      Load JSON from PATH; merge into SRC. Each file is
                          always SRC1, SRC2, … by order; with LABEL=, also under LABEL.
                          Repeatable.
  --ctxt [LABEL=]PATH     Load JSON like --src; shallow-merge into CTXT and expose
                          as CTXT1, CTXT2, … (and optional LABEL). Not merged into
                          SRC or printed output. Repeatable.
  --srcvar PATH.TO.KEY=VALUE
                          Patch a string into merged SRC (Jinja-style path before
                          the first =). Repeatable.
  --srcvar-root PATH      Prepend PATH. to every --srcvar key (same path syntax).
  --var PATH.TO.KEY=VALUE Patch a string into merged CTXT (same path rules as
                          --srcvar). Not merged into printed SRC. Repeatable.
  --var-root PATH         Prepend PATH. to every --var key (same path syntax).
  -o, --output FILE       Write the resolved JSON to FILE instead of stdout.
  --compact               Emit compact (single-line) JSON instead of indented.
  --sort-keys             Sort object keys in JSON output (default: source order).
  --debug                 Keep $include sources under //$include / //$include.*;
                          keep $src paths under //$src, //$src.LABEL,
                          and //$ctxt.LABEL.
  --version               Show the version and exit.
  -h, --help              Show usage and exit.
```

Errors that arise from arguments (missing files, malformed `PATH` or `LABEL=PATH`,
reserved labels, clashes between `--src` and `--ctxt` names) exit with a usage error. Resolution problems (undefined
variables, bad template syntax, cyclic references) exit with a clear
`Error: failed to resolve $.path...` message that points at the JSON path
where the problem was found.

## Programmatic API

The CLI and the library share one pipeline: build namespaces, resolve merged
`SRC`, optionally serialize JSON. Pick the entry point that matches how much
control you need:


| Tier | Entry                            | Use when                                                                                                   |
| ---- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 1    | `resolve_cli(argv)`              | You want the same flags as the terminal (`--src`, `--var`, …).                                             |
| 2    | `build_and_resolve(src_args, …)` | You already have token tuples (or build them yourself) and want the full run without Click or JSON output. |
| 3    | `build_namespaces` + `resolve`   | You supply or mutate `SRC`, namespaces, or non-CLI JSON before resolving.                                  |


**Tier 1** — parse CLI-style arguments:

```python
from eksp import resolve_cli

resolved, namespaces = resolve_cli(
    ["--src", "cfg1.json", "--ctxt", "fragments.json", "--var", "name1=abc"]
)
# Or: resolve_cli("--src cfg1.json --var name1=abc")
# Or: resolve_cli()  # uses sys.argv[1:]
```

`resolve_cli` returns `(resolved, namespaces)` and also honors serialization
flags: `--output` writes JSON, and `--compact` / `--sort-keys` control that
serialization format. `NamespaceError` and `ResolutionError` are raised on
failure.

To reuse CLI parsing without running the full pipeline, use
`group_cli_args` — it returns only the non-empty `src_args`, `ctxt_args`,
`srcvar_args`, and `var_args` tuples (same rules as the terminal; output flags
are ignored):

```python
from eksp import build_and_resolve, group_cli_args

grouped = group_cli_args(["--src", "cfg1.json", "--var", "name1=abc"])
resolved, namespaces = build_and_resolve(
    grouped.get("src_args", ()),
    grouped.get("ctxt_args", ()),
    grouped.get("srcvar_args", ()),
    grouped.get("var_args", ()),
)
```

**Tier 2** — same work as `eksp` without printing (what `resolve_cli` calls
internally):

```python
from eksp import build_and_resolve

resolved, namespaces = build_and_resolve(
    ("cfg1.json", "R2=cfg2.json"),
    ("fragments.json",),
    ("owner=pat",),
    ("name1=abc", "solo=pat"),
    environ={"DB_HOST": "db.example.com"},
)
```

Each `src_args` / `ctxt_args` element may be either:

- a CLI token (`PATH` or `LABEL=PATH`), or
- a direct source tuple `(label_or_none, source)` where `source` is a path-like
value or an in-memory dict.

**Tier 3** — build namespaces and resolve explicitly (custom `SRC`, structural
keys in JSON, tests):

```python
from eksp import build_namespaces, parse_var_arg, parse_srcvar_arg, resolve

namespaces = build_namespaces(
    sources=[(None, "cfg1.json"), ("R2", "cfg2.json")],
    ctxt_sources=[(None, "fragments.json")],
    srcvars=[parse_srcvar_arg("owner=pat")],
    vars=[
        parse_var_arg("name1=abc"),
        parse_var_arg("solo=pat"),
        (("meta", "v"), "1"),
    ],
    environ={"DB_HOST": "db.example.com"},
)
resolved = resolve(namespaces["SRC"], namespaces, debug=False)
```

`resolve` accepts any JSON-shaped Python value (dict, list, scalar, string),
walks it recursively, renders strings to their fixed point, and expands
`$include` / `$include.*` and root-level `$src` / `$ctxt` keys as described
below. It also accepts a JSON file path as the first argument. Pass
`debug=True` to attach `//$include*` diagnostics on objects that used includes.

## Namespaces

`eksp` exposes the following Jinja namespaces, all reachable as
`{{ NAMESPACE.path.to.value }}`:


| Namespace            | Built from                                                                 | Notes                                                                            |
| -------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `ENV`                | `os.environ`                                                               | reserved - cannot be used as a label                                             |
| `SRC`                | shallow last-wins merge of every `--src` document, then `--srcvar` patches | reserved - cannot be used as a label                                             |
| `SRC1`, `SRC2`, …    | each `--src` invocation, in order (always)                                 | snapshot of **that line’s file only** (not the merged custom label)              |
| `CTXT`               | shallow last-wins merge of every `--ctxt` document, then `--var` patches   | reserved - cannot be used as a `--ctxt` label                                    |
| `CTXT1`, `CTXT2`, …  | each `--ctxt` invocation, in order (always)                                | snapshot of that line’s file only; not merged into `SRC`                         |
| `<LABEL>` (`--src`)  | `--src LABEL=path` (repeat `LABEL` to merge several files)                 | shallow merge of all files for that label; first use shares `SRC#` for that line |
| `<LABEL>` (`--ctxt`) | `--ctxt LABEL=path` (repeatable per label)                                 | shallow merge for that label; not merged into printed output                     |


Lookups follow Jinja's normal attribute and index syntax. The first segment
(before the first `.` or `[`) selects the namespace, e.g. `{{ SRC.db.host }}`,
`{{ SRC.users[0].name }}`, `{{ ENV.HOME }}`, `{{ CTXT1.fragment }}`, or `{{ CTXT.solo }}` from merged `CTXT`.

### `--srcvar` (merged into `SRC`)

After all `--src` files are shallow-merged, each `--srcvar` patches a string
into that same `SRC` object. The key (left of the first `=`) is a Jinja-style
path without a leading namespace label: `service.host`, `meta['x.y']`,
`.optional_flag` (leading `.` is allowed), etc. The value is the rest of the
argument (may contain `=`). Later `--srcvar` assignments win on the same leaf.

Unquoted numeric brackets build lists: `items[0]=a` → `"items": ["a"]`.
Quoted brackets are dict keys: `meta['0']=x` → `"meta": {"0": "x"}`. The same
rules apply to `--var`.

Optional `--srcvar-root PATH` prepends `PATH.` to every `--srcvar` key (e.g.
`--srcvar-root meta` and `--srcvar host=x` set `meta.host`). Bracket segments
work: `--srcvar-root meta['x.y']` with `--srcvar port=5432` sets `meta['x.y'].port`.

### `--var` (merged into `CTXT`)

After all `--ctxt` files are shallow-merged into `CTXT`, each `--var` patches a
string into that same `CTXT` object using the same path syntax as `--srcvar`.
Assignments are visible to templates (e.g. `{{ CTXT.name1 }}`) but
are not shallow-merged into the printed `SRC` output.

The first `=` separates the whole key from the value; the value may contain
`=`. Inside quoted bracket strings, use `\\` to escape a quote or backslash.

If you set a shallow key to a string and later try to set a key under it
(`--var a=1` then `--var a.b=2`), `eksp` raises an error because an existing
string cannot become an object.

Optional `--var-root PATH` prepends `PATH.` to every `--var` key, with the same
rules as `--srcvar-root`.

### Dots in property names

Namespaces are ordinary Python dicts from JSON (and string values from
`--srcvar` / `--var`). Dot notation always walks nested keys: `{{ SRC.db.host }}` is
`SRC["db"]["host"]`, not a single key named `"db.host"`.

If a JSON object uses a key that contains a dot (or other characters that
are awkward in attribute syntax), use bracket / subscript form — no extra
`eksp` escaping is required:

```json
{ "db.host": "postgres.internal", "url": "{{ SRC['db.host'] }}/app" }
```

You can chain brackets and dots as needed, e.g. `{{ SRC['a.b'].port }}` when
`"a.b"` maps to an object with a `port` field.

The same rules apply on the left-hand side of `--var` and `--srcvar` (see above): use `meta['db.host']=value` when the JSON key contains a dot.

### Merge strategy for `SRC`

`SRC` is the shallow, last-wins merge of every `--src` document, in
the order they were specified on the command line, then patched by every
`--srcvar` (also last-wins at each leaf). Top-level keys from later
files completely replace top-level keys from earlier files (nested objects
are *not* merged recursively). If you need nested overrides, model them
explicitly inside a single config file.

You may pass `--src PATH` (no label): the file is only registered as `SRC1`,
`SRC2`, … in order. With `--src LABEL=PATH`, the file is registered under the
matching `SRC#` for that line and under `LABEL`. The first `--src LABEL=…` for a
given `LABEL` uses the same dict as that line’s `SRC#`; further `--src LABEL=…` lines shallow-merge into `LABEL` only (earlier `SRC#` slots stay that
file’s snapshot).

Per-file snapshots remain under `SRC#` / `CTXT#`. Custom labels (for example
`R1`, `R2`, or repeated `parts`) hold the shallow merge of every CLI line that
used that label.

### Context files (`--ctxt`)

`--ctxt` uses the same `[LABEL=]PATH` form as `--src`. Each file is loaded as a
JSON object, shallow-merged into `CTXT` (last-wins at the top level, like `SRC`),
and exposed as `CTXT1`, `CTXT2`, … in order, and under an optional custom label
(the same dict as the matching `CTXT#`). Templates may use `{{ CTXT.key }}` for
the merge or `{{ CTXT1.key }}` for a single file. Context is not merged
into `SRC` or the printed JSON unless `--src` content references it.

Namespace names from `--src` and `--ctxt` share one flat registry: the same custom
label cannot be used on both `--src` and `--ctxt`. You may repeat a label on the
same option (`--src R1=a.json --src R1=b.json`); files are shallow-merged into
that label namespace in order (like a JSON list on `$src.<LABEL>`). Each
`--src` / `--ctxt` invocation still gets its own `SRC#` / `CTXT#` slot pointing
at that file only. Implicit slot names `SRC#` are reserved for `--src` only and
`CTXT#` for `--ctxt` only. `CTXT` itself is reserved (like `SRC`) and cannot be
used as a `--ctxt` label.

### Choosing CLI flags vs structural keys

Both mechanisms can load files and bind a namespace `LABEL`, but they hook in
at different stages and are not fully interchangeable.


|                          | `--src` / `--ctxt`                        | `$src.<LABEL>` / `$ctxt.<LABEL>` (root only)         |
| ------------------------ | ----------------------------------------- | ---------------------------------------------------- |
| When files load          | Before resolve (`build_namespaces`)       | During resolve at the document root                  |
| Merges into `SRC`        | Every `--src` file, always                | No (only into printed output for `$src.`*)           |
| Merges into printed JSON | Via resolving all of `SRC`                | Shallow-merge **resolved** keys at root for `$src.`* |
| Merges into `CTXT`       | Every `--ctxt` file                       | Only for `$ctxt.`* (raw shallow merge)               |
| Several files, one label | Repeat `--src LABEL=…` / `--ctxt LABEL=…` | One key, value is a path string or list of paths     |


**Practical rules:**

1. Use `**--src` / `--ctxt`** when inputs are invocation-time (scripts, CI,
  local paths) and should contribute to merged `SRC` / `CTXT`.
2. Use `**$src.<LABEL>` / `$ctxt.<LABEL>`** when paths belong in the config
  tree and you need root-level merge of a resolved subtree without listing every
   file on the command line.
3. Use **one binding per label per run**: if `LABEL` is already set (CLI,
  `build_namespaces`, or a pre-filled entry in `namespaces`), matching
   structural keys are **ignored with a warning** and their paths are **not**
   loaded.
4. Do not expect `--src frag=file.json` and `"$src.frag": "other.json"` to
  combine; CLI wins and `other.json` is never read.

Bare `$src` (a list of paths, `$src` tree) is separate: it does not replace
labeled `--src` for building `SRC`.

### Per-object resolution order

`resolve` walks the merged JSON recursively. For each object (at any depth),
`$include` / `$include.<name>` follow the order below.

At the resolve root (`$`) only, structural `$src` / `$ctxt` keys run first (nested
`$src`, `$src.<LABEL>`, or `$ctxt.<LABEL>` emit a warning and are ignored):

1. `$ctxt.<LABEL>` (if any) — load path(s), bind namespace `LABEL`, shallow-merge
  into `CTXT` (like `--ctxt LABEL=path`; not merged into printed `SRC`). Ignored
  with a warning if `LABEL` was already bound (e.g. `--ctxt LABEL=path`).
2. `$src.<LABEL>` (if any) — load path(s), bind namespace `LABEL`, shallow-merge
  into the output (like `--src LABEL=path`). Ignored with a warning if `LABEL`
  was already bound (e.g. `--src LABEL=path`).
3. `$src` (if present) — shallow-merge listed files into the output via the
  `$src` tree (see [$src flattening and //$src](#src-flattening)).

On every object (including nested):

1. `$include` — dict or list of dicts, shallow-merged in order.
2. `$include.<name>` — one value or merged fragment list.
3. Ordinary keys — last; win on name clashes.

Throughout, every string is rendered with Jinja to a fixed point when that
value is visited (including path strings in `$src` / `$ctxt` bindings).

#### $src flattening and `//$src`

When building the merge list for a given object’s `$src`, `eksp` loads
each file’s top-level JSON object and follows only that file’s top-level
`$src` to walk the dependency tree (deduplicated, depth-first). With
`--debug`, `//$src` on that same object lists those flattened paths.

A nested object with `$src` or `$src.<LABEL>` is ignored with a warning.
Files loaded by a root-level `$src` may still declare their own
top-level `$src` lists (chain behavior).

## `$include` and `--debug`

Inside any JSON object you may use:

- `$include` — a **namespace reference** string (e.g. `"SRC.fragments"` or
`"FRAG.defaults"`), a JSON object, or a JSON array of references/objects.
Reference strings use the same path rules as Jinja (`SRC.db.host`,
`SRC['db.host']`, `CTXT1.items`, etc.) but without `{{ }}`. If the value
contains `{{ ... }}`, it is rendered first and must become a bare reference
string (not an inlined dict/list). Included dicts are
shallow-merged in order after any `$src` on the same object (see
[Per-object resolution order](#per-object-resolution-order)); later entries win
on top-level key clashes. Example: `"$include": ["FRAG1", "FRAG2", {"extra": 1}]`.
- `$include.<name>` — e.g. `"$include.options": "SRC.defaults"`. Same reference
rules as bare `$include`. A JSON array is a fragment list (each entry may be a
reference string, object, or list). Only homogeneous fragment lists are allowed
(all dicts shallow-merged, or all lists concatenated). A single reference that
resolves to one list (e.g. `"$include.tags": "ENV.TAGS"`) is one value, not a
fragment list. Whole-value templates like `"{{ ENV.TAGS }}"` are not supported
here (use `"ENV.TAGS"` or `"{{ ENV.TAGS_KEY }}"` when the key holds a path).

Relative to any `$src` on the same object, processing follows
[Per-object resolution order](#per-object-resolution-order) above: bare `$include`, then each `$include.<name>`, then
ordinary keys.

### Equivalence

A single template that evaluates to a list of dicts is the same as writing that
list inline. If `SRC.fragments` is:

```json
[
  {"host": "{{ ENV.DB_HOST }}", "port": 5432},
  {"host": "backup.example.com", "ssl": true}
]
```

then these forms are equivalent:

```json
"$include": "SRC.fragments"
```

matches an inline list:

```json
"$include": [
  {"host": "{{ ENV.DB_HOST }}", "port": 5432},
  {"host": "backup.example.com", "ssl": true}
]
```

Each fragment is resolved and shallow-merged in order; later fragments win on
duplicate top-level keys; ordinary keys on the same object still run last. The
template form is useful when the fragment list lives in a namespace (from
`--src`, `--var`, another file, etc.) instead of being duplicated in the config.

If the expression evaluates to a single dict, that is treated as a one-item
include (same as before). If it evaluates to a list whose items are not dicts
(after any per-item template evaluation), resolution fails with an error at
`$.$include[n]`.

### `--debug`

When the `--debug` flag is set, objects that used `$include` or `$include.<name>` also
emit `//$include` and `//$include.<name>` keys with the raw input values
(e.g. the template string before evaluation). Objects that used `$src` also
emit `//$src` (see [$src flattening and //$src](#src-flattening)).

## `$src` and `$ctxt`

Only on the **resolve root** (`$`, i.e. the document passed to `resolve` — merged
`SRC` for the CLI). Nested `$src`, `$src.<LABEL>`, or `$ctxt.<LABEL>` keys
are ignored with a warning.

### `$src` — merge into output

```json
{
  "$src": ["./base.json", "{{ ENV.CONFIG_DIR }}/more.json"],
  "service": "api"
}
```

- Value must be a list of path strings.
- Files are shallow-merged into the output via the `$src` tree (see
[$src flattening and //$src](#src-flattening)).
- Loaded files may declare their own top-level `$src` lists (chains).

### `$src.<LABEL>` — merge into output and namespace

```json
{
  "$src.fragments": "./fragments.json",
  "$src.defaults": ["./defaults.json", "./overrides.json"],
  "items": "{{ fragments.items }}"
}
```

Similar to repeating `--src LABEL=path` for the **label namespace** and a
root-level merge, but **not** the same as `--src`: every `--src` file still
feeds merged `SRC`; `$src.<LABEL>` only shallow-merges a **resolved** subtree
into the printed output at root (last-wins at the top level) and binds
`{{ LABEL }}`. The raw document (before subtree resolution) is kept on the label
namespace. If `LABEL` was already bound (CLI or caller), this key is ignored
and the path(s) are not loaded.

### `$ctxt.<LABEL>` — context only (not in printed `SRC`)

```json
{
  "$ctxt.side": "./side.json",
  "rollout": "{{ side.flag }}"
}
```

Similar to `--ctxt LABEL=path` for **CTXT** and `{{ LABEL }}`, but only when
`LABEL` is not already bound; otherwise ignored (paths not loaded). Does not add
keys to the printed output unless a template references them.

### Shared rules

- Path value: one string or a list of strings (lists shallow-merge, last-wins).
- `LABEL` naming matches `--src` / `--ctxt` (see Namespaces).
- If `LABEL` is already bound (CLI, `build_namespaces`, or a pre-filled
`namespaces` entry), the structural key is ignored with a warning and its
path(s) are not read.
- Structural `$src` / `$ctxt` keys are stripped from loaded files before binding.
- Paths may use Jinja `{{ ... }}`; relative paths use the resolve root directory.
- Ordinary keys on the root object still override merged `$src` keys.

## Recursive resolution

For each string value `eksp` walks, the Jinja environment is invoked
repeatedly until the rendered text equals the input text. That allows chains
like:

```json
{ "a": "{{ SRC.b }}", "b": "{{ SRC.c }}", "c": "deepest" }
```

to resolve all the way down to `"deepest"` while still terminating cleanly on
values that have no template markers (they render to themselves on the first
pass).

If a string is only one `{{ ... }}` expression (optional whitespace), the
result keeps the expression’s native type: `{"cfg": "{{ SRC.cfg }}"}` can
become a JSON object, not a serialized string. Text that mixes literals and
templates (e.g. `"host={{ SRC.db.host }}"`) still becomes a string.

A safety cap (100 iterations) is applied to surface cyclic references as a
clear error rather than hanging.

Non-string scalars (`int`, `float`, `bool`, `null`) in the input JSON pass
through untouched when they are not inside a template string.

## Development

```bash
uv sync                      # install runtime + dev dependencies
uv run pytest                # run the test suite
uv run pytest --cov=eksp     # run with coverage
uv run ruff check .          # lint
uv run ruff format .         # format
```

The project uses a `src/` layout, `pytest` for tests, and `ruff` for
lint+format. Tests live in `tests/`, with JSON fixtures under
`tests/fixtures/`.

### Releasing

Set `version` in `pyproject.toml` and `__version__` in `src/eksp/__init__.py` (keep
them in sync), then build and publish with [uv](https://docs.astral.sh/uv/).
Always remove `dist/` before building so `uv publish` (which uploads `dist/*` by
default) does not upload stale wheels from earlier versions.

```bash
uv run pytest -q             # optional check before release
rm -rf dist/ build/
uv build                     # dist/eksp-<version>-py3-none-any.whl + .tar.gz
ls dist/

# TestPyPI
UV_PUBLISH_TOKEN=pypi-... \
  uv publish --publish-url https://test.pypi.org/legacy/

# Production PyPI
UV_PUBLISH_TOKEN=pypi-... \
  uv publish

# Optional: skip files already on the index
# uv publish --check-url https://pypi.org/simple/
# uv publish --dry-run
```

Create the token at [pypi.org](https://pypi.org/manage/account/token/) or
[TestPyPI](https://test.pypi.org/manage/account/token/). To install from
TestPyPI (dependencies still come from PyPI):

```bash
uv pip install --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  eksp==<version>
```

Old files under `dist/` are local-only (gitignored) and safe to delete with
`rm -rf dist/`. Cleaning TestPyPI releases is done on the TestPyPI website;
production PyPI releases cannot be deleted—yank unwanted versions on the project
page if needed.

## License

MIT. See [LICENSE](LICENSE).