Skip to main content

Resolve Jinja-style {{ ... }} references in JSON configurations from layered namespaces.

Project description

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.

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

eksp-0.10.0.tar.gz (69.0 kB view details)

Uploaded Source

Built Distribution

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

eksp-0.10.0-py3-none-any.whl (32.8 kB view details)

Uploaded Python 3

File details

Details for the file eksp-0.10.0.tar.gz.

File metadata

  • Download URL: eksp-0.10.0.tar.gz
  • Upload date:
  • Size: 69.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for eksp-0.10.0.tar.gz
Algorithm Hash digest
SHA256 faf76d4ca170de68cc4bc51d135088bbbd49ec6b5f6d1cf77431c04c98f79792
MD5 4bf204c74f0d8653e123017b99f439d7
BLAKE2b-256 269095d13dd86658afdfe3fe5110139201c5ae1c5b04db5c7ec6f7b977afe0f9

See more details on using hashes here.

File details

Details for the file eksp-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: eksp-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 32.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.9 {"installer":{"name":"uv","version":"0.11.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for eksp-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 13bdbeab4838eee538ba635f0ec2394167d538629cb2a57450a2286b8bdcc76c
MD5 6e5a64dbbdc2b94e0ac6c816207cf013
BLAKE2b-256 65003b1458ebed8edcd05589f5a7a75896a694ff8ece3cb547ff4c4e8251f849

See more details on using hashes here.

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