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 (
PATHorLABEL=PATH), or - a direct source tuple
(label_or_none, source)wheresourceis 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:
- Use
**--src/--ctxt** when inputs are invocation-time (scripts, CI, local paths) and should contribute to mergedSRC/CTXT. - 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. - Use one binding per label per run: if
LABELis already set (CLI,build_namespaces, or a pre-filled entry innamespaces), matching structural keys are ignored with a warning and their paths are not loaded. - Do not expect
--src frag=file.jsonand"$src.frag": "other.json"to combine; CLI wins andother.jsonis 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):
$ctxt.<LABEL>(if any) — load path(s), bind namespaceLABEL, shallow-merge intoCTXT(like--ctxt LABEL=path; not merged into printedSRC). Ignored with a warning ifLABELwas already bound (e.g.--ctxt LABEL=path).$src.<LABEL>(if any) — load path(s), bind namespaceLABEL, shallow-merge into the output (like--src LABEL=path). Ignored with a warning ifLABELwas already bound (e.g.--src LABEL=path).$src(if present) — shallow-merge listed files into the output via the$srctree (see $src flattening and //$src).
On every object (including nested):
$include— dict or list of dicts, shallow-merged in order.$include.<name>— one value or merged fragment list.- 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$srcon 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
$srctree (see $src flattening and //$src). - Loaded files may declare their own top-level
$srclists (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).
LABELnaming matches--src/--ctxt(see Namespaces).- If
LABELis already bound (CLI,build_namespaces, or a pre-fillednamespacesentry), the structural key is ignored with a warning and its path(s) are not read. - Structural
$src/$ctxtkeys 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
$srckeys.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
faf76d4ca170de68cc4bc51d135088bbbd49ec6b5f6d1cf77431c04c98f79792
|
|
| MD5 |
4bf204c74f0d8653e123017b99f439d7
|
|
| BLAKE2b-256 |
269095d13dd86658afdfe3fe5110139201c5ae1c5b04db5c7ec6f7b977afe0f9
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13bdbeab4838eee538ba635f0ec2394167d538629cb2a57450a2286b8bdcc76c
|
|
| MD5 |
6e5a64dbbdc2b94e0ac6c816207cf013
|
|
| BLAKE2b-256 |
65003b1458ebed8edcd05589f5a7a75896a694ff8ece3cb547ff4c4e8251f849
|