Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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 is the recommended toolchain.

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:

uv tool install .
eksp --help

Or with plain pip:

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:

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

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:

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

Given cfg1.json:

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

and cfg2.json:

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

eksp prints:

{
  "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:

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

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

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

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:

{ "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).

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

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

then these forms are equivalent:

"$include": "SRC.fragments"

matches an inline list:

"$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 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

{
  "$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).
  • Loaded files may declare their own top-level $src lists (chains).

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

{
  "$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)

{
  "$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:

{ "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

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. Always remove dist/ before building so uv publish (which uploads dist/* by default) does not upload stale wheels from earlier versions.

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 or TestPyPI. To install from TestPyPI (dependencies still come from PyPI):

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.

About

Merge and resolve JSON configurations that use special features (Jinja-style variables, import, include)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages