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.
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 venvInstall as a global tool:
uv tool install .
eksp --helpOr with plain pip:
pip install .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 folderYou 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=ghiOr name sources explicitly:
eksp \
--src R1=cfg1.json \
--src R2=cfg2.json \
--var name1=abc \
--var name2=def \
--var name3=ghiGiven 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"]
}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.
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.
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.
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.
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.
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.
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.
--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.
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.
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).
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).
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.
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].
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).
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": ["./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.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.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.
- 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.
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.
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 . # formatThe project uses a src/ layout, pytest for tests, and ruff for
lint+format. Tests live in tests/, with JSON fixtures under
tests/fixtures/.
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-runCreate 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.
MIT. See LICENSE.