eksp 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eksp-0.9.0/.gitignore ADDED
@@ -0,0 +1,30 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # uv
18
+ .uv/
19
+
20
+ # Testing & coverage
21
+ .pytest_cache/
22
+ .coverage
23
+ .coverage.*
24
+ htmlcov/
25
+ coverage.xml
26
+
27
+ # Editors / OS
28
+ .idea/
29
+ .vscode/
30
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
eksp-0.9.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 George Dogaru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
eksp-0.9.0/PKG-INFO ADDED
@@ -0,0 +1,639 @@
1
+ Metadata-Version: 2.4
2
+ Name: eksp
3
+ Version: 0.9.0
4
+ Summary: Resolve Jinja-style {{ ... }} references in JSON configurations from layered namespaces.
5
+ Project-URL: Repository, https://github.com/gddata/eksp
6
+ Author: George Dogaru
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: cli,config,jinja,json,templating
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: jinja2>=3.1
25
+ Description-Content-Type: text/markdown
26
+
27
+ # eksp
28
+
29
+ `eksp` is a small Python CLI that resolves Jinja-style `{{ ... }}` references
30
+ inside JSON configurations. You point it at one or more JSON files and optional
31
+ `--srcvar` / `--var` string assignments; it merges `--src` inputs,
32
+ patches `--srcvar` into that merged document, shallow-merges `--ctxt` into
33
+ `CTXT` and patches `--var` there, expands references against those dictionaries (plus
34
+ `ENV`), and prints the resolved JSON.
35
+
36
+ It is intentionally minimal:
37
+
38
+ - Only Jinja's `{{ ... }}` substitution is part of the contract - no `{% %}`
39
+ blocks, loops, filters, or macros are documented.
40
+ - Variables are looked up by the part of the expression before the first `.`
41
+ or `[`. That part names a top-level namespace dictionary; the rest of the
42
+ expression follows Jinja's standard attribute / item access.
43
+ - Each string value is re-rendered until two consecutive renders produce the
44
+ same text (a fixed-point check), which both expands chained references and
45
+ prevents infinite recursion.
46
+
47
+ ## Installation
48
+
49
+ [uv](https://docs.astral.sh/uv/) is the recommended toolchain.
50
+
51
+ ```bash
52
+ git clone <this-repo> eksp
53
+ cd eksp
54
+ uv sync # installs runtime + dev dependencies into .venv
55
+ uv run eksp --help # run from the project venv
56
+ ```
57
+
58
+ Install as a global tool:
59
+
60
+ ```bash
61
+ uv tool install .
62
+ eksp --help
63
+ ```
64
+
65
+ Or with plain `pip`:
66
+
67
+ ```bash
68
+ pip install .
69
+ ```
70
+
71
+ ## Sample configs
72
+
73
+ `eksp-sample` creates a folder (default `./eksp-sample`) with three example JSON
74
+ files and a README that shows the exact `eksp` command to run:
75
+
76
+ ```bash
77
+ uv run eksp-sample # or: eksp-sample
78
+ cd eksp-sample
79
+ # follow README.md inside the folder
80
+ ```
81
+
82
+ ## Quick start
83
+
84
+ You can omit the label: each file is still available as `SRC1`, `SRC2`, …
85
+ (see Namespaces).
86
+
87
+ ```bash
88
+ export DB_HOST=db.example.com
89
+ export DATE=2026-05-06
90
+
91
+ eksp \
92
+ --src cfg1.json \
93
+ --src cfg2.json \
94
+ --var name1=abc \
95
+ --var name2=def \
96
+ --var name3=ghi
97
+ ```
98
+
99
+ Or name sources explicitly:
100
+
101
+ ```bash
102
+ eksp \
103
+ --src R1=cfg1.json \
104
+ --src R2=cfg2.json \
105
+ --var name1=abc \
106
+ --var name2=def \
107
+ --var name3=ghi
108
+ ```
109
+
110
+ Given `cfg1.json`:
111
+
112
+ ```json
113
+ {
114
+ "service": "billing",
115
+ "db": { "host": "{{ ENV.DB_HOST }}", "port": 5432 },
116
+ "owner": "{{ CTXT.name1 }}"
117
+ }
118
+ ```
119
+
120
+ and `cfg2.json`:
121
+
122
+ ```json
123
+ {
124
+ "today": "Today is {{ ENV.DATE }}.",
125
+ "rollout": ["{{ CTXT.name3 }}", "{{ CTXT.name2 }}"]
126
+ }
127
+ ```
128
+
129
+ `eksp` prints:
130
+
131
+ ```json
132
+ {
133
+ "service": "billing",
134
+ "db": { "host": "db.example.com", "port": 5432 },
135
+ "owner": "abc",
136
+ "today": "Today is 2026-05-06.",
137
+ "rollout": ["ghi", "def"]
138
+ }
139
+ ```
140
+
141
+ ## CLI reference
142
+
143
+ ```
144
+ eksp [OPTIONS]
145
+
146
+ --src [LABEL=]PATH Load JSON from PATH; merge into SRC. Each file is
147
+ always SRC1, SRC2, … by order; with LABEL=, also under LABEL.
148
+ Repeatable.
149
+ --ctxt [LABEL=]PATH Load JSON like --src; shallow-merge into CTXT and expose
150
+ as CTXT1, CTXT2, … (and optional LABEL). Not merged into
151
+ SRC or printed output. Repeatable.
152
+ --srcvar PATH.TO.KEY=VALUE
153
+ Patch a string into merged SRC (Jinja-style path before
154
+ the first =). Repeatable.
155
+ --srcvar-root PATH Prepend PATH. to every --srcvar key (same path syntax).
156
+ --var PATH.TO.KEY=VALUE Patch a string into merged CTXT (same path rules as
157
+ --srcvar). Not merged into printed SRC. Repeatable.
158
+ --var-root PATH Prepend PATH. to every --var key (same path syntax).
159
+ -o, --output FILE Write the resolved JSON to FILE instead of stdout.
160
+ --compact Emit compact (single-line) JSON instead of indented.
161
+ --sort-keys Sort object keys in JSON output (default: source order).
162
+ --debug Keep $include sources under //$include / //$include.*;
163
+ keep $src paths under //$src, //$src.LABEL,
164
+ and //$ctxt.LABEL.
165
+ --version Show the version and exit.
166
+ -h, --help Show usage and exit.
167
+ ```
168
+
169
+ Errors that arise from arguments (missing files, malformed `PATH` or `LABEL=PATH`,
170
+ reserved labels, clashes between `--src` and `--ctxt` names) exit with a usage error. Resolution problems (undefined
171
+ variables, bad template syntax, cyclic references) exit with a clear
172
+ `Error: failed to resolve $.path...` message that points at the JSON path
173
+ where the problem was found.
174
+
175
+ ## Programmatic API
176
+
177
+ The CLI and the library share one pipeline: build namespaces, resolve merged
178
+ `SRC`, optionally serialize JSON. Pick the entry point that matches how much
179
+ control you need:
180
+
181
+
182
+ | Tier | Entry | Use when |
183
+ | ---- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
184
+ | 1 | `resolve_cli(argv)` | You want the same flags as the terminal (`--src`, `--var`, …). |
185
+ | 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. |
186
+ | 3 | `build_namespaces` + `resolve` | You supply or mutate `SRC`, namespaces, or non-CLI JSON before resolving. |
187
+
188
+
189
+ **Tier 1** — parse CLI-style arguments:
190
+
191
+ ```python
192
+ from eksp import resolve_cli
193
+
194
+ resolved, namespaces = resolve_cli(
195
+ ["--src", "cfg1.json", "--ctxt", "fragments.json", "--var", "name1=abc"]
196
+ )
197
+ # Or: resolve_cli("--src cfg1.json --var name1=abc")
198
+ # Or: resolve_cli() # uses sys.argv[1:]
199
+ ```
200
+
201
+ `--output`, `--compact`, and `--sort-keys` are ignored with a warning
202
+ (serialization is up to you). `NamespaceError` and `ResolutionError` are raised
203
+ on failure.
204
+
205
+ **Tier 2** — same work as `eksp` without printing (what `resolve_cli` calls
206
+ internally):
207
+
208
+ ```python
209
+ from eksp import build_and_resolve
210
+
211
+ resolved, namespaces = build_and_resolve(
212
+ ("cfg1.json", "R2=cfg2.json"),
213
+ ("fragments.json",),
214
+ ("owner=pat",),
215
+ ("name1=abc", "solo=pat"),
216
+ environ={"DB_HOST": "db.example.com"},
217
+ )
218
+ ```
219
+
220
+ Each `src_args` / `ctxt_args` element is one CLI token (`PATH` or `LABEL=PATH`).
221
+
222
+ **Tier 3** — build namespaces and resolve explicitly (custom `SRC`, structural
223
+ keys in JSON, tests):
224
+
225
+ ```python
226
+ from eksp import build_namespaces, parse_var_arg, parse_srcvar_arg, resolve
227
+
228
+ namespaces = build_namespaces(
229
+ sources=[(None, "cfg1.json"), ("R2", "cfg2.json")],
230
+ ctxt_sources=[(None, "fragments.json")],
231
+ srcvars=[parse_srcvar_arg("owner=pat")],
232
+ vars=[
233
+ parse_var_arg("name1=abc"),
234
+ parse_var_arg("solo=pat"),
235
+ (("meta", "v"), "1"),
236
+ ],
237
+ environ={"DB_HOST": "db.example.com"},
238
+ )
239
+ resolved = resolve(namespaces["SRC"], namespaces, debug=False)
240
+ ```
241
+
242
+ `resolve` accepts any JSON-shaped Python value (dict, list, scalar, string),
243
+ walks it recursively, renders strings to their fixed point, and expands
244
+ `$include` / `$include.*` and root-level `$src` / `$ctxt` keys as described
245
+ below. Pass `debug=True` to attach `//$include*` diagnostics on objects that
246
+ used includes.
247
+
248
+ ## Namespaces
249
+
250
+ `eksp` exposes the following Jinja namespaces, all reachable as
251
+ `{{ NAMESPACE.path.to.value }}`:
252
+
253
+
254
+ | Namespace | Built from | Notes |
255
+ | -------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
256
+ | `ENV` | `os.environ` | reserved - cannot be used as a label |
257
+ | `SRC` | shallow last-wins merge of every `--src` document, then `--srcvar` patches | reserved - cannot be used as a label |
258
+ | `SRC1`, `SRC2`, … | each `--src` invocation, in order (always) | snapshot of **that line’s file only** (not the merged custom label) |
259
+ | `CTXT` | shallow last-wins merge of every `--ctxt` document, then `--var` patches | reserved - cannot be used as a `--ctxt` label |
260
+ | `CTXT1`, `CTXT2`, … | each `--ctxt` invocation, in order (always) | snapshot of that line’s file only; not merged into `SRC` |
261
+ | `<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 |
262
+ | `<LABEL>` (`--ctxt`) | `--ctxt LABEL=path` (repeatable per label) | shallow merge for that label; not merged into printed output |
263
+
264
+
265
+ Lookups follow Jinja's normal attribute and index syntax. The first segment
266
+ (before the first `.` or `[`) selects the namespace, e.g. `{{ SRC.db.host }}`,
267
+ `{{ SRC.users[0].name }}`, `{{ ENV.HOME }}`, `{{ CTXT1.fragment }}`, or `{{ CTXT.solo }}` from merged `CTXT`.
268
+
269
+ ### `--srcvar` (merged into `SRC`)
270
+
271
+ After all `--src` files are shallow-merged, each `--srcvar` patches a string
272
+ into that same `SRC` object. The key (left of the first `=`) is a Jinja-style
273
+ path without a leading namespace label: `service.host`, `meta['x.y']`,
274
+ `.optional_flag` (leading `.` is allowed), etc. The value is the rest of the
275
+ argument (may contain `=`). Later `--srcvar` assignments win on the same leaf.
276
+
277
+ Unquoted numeric brackets build lists: `items[0]=a` → `"items": ["a"]`.
278
+ Quoted brackets are dict keys: `meta['0']=x` → `"meta": {"0": "x"}`. The same
279
+ rules apply to `--var`.
280
+
281
+ Optional `--srcvar-root PATH` prepends `PATH.` to every `--srcvar` key (e.g.
282
+ `--srcvar-root meta` and `--srcvar host=x` set `meta.host`). Bracket segments
283
+ work: `--srcvar-root meta['x.y']` with `--srcvar port=5432` sets `meta['x.y'].port`.
284
+
285
+ ### `--var` (merged into `CTXT`)
286
+
287
+ After all `--ctxt` files are shallow-merged into `CTXT`, each `--var` patches a
288
+ string into that same `CTXT` object using the same path syntax as `--srcvar`.
289
+ Assignments are visible to templates (e.g. `{{ CTXT.name1 }}`) but
290
+ are not shallow-merged into the printed `SRC` output.
291
+
292
+ The first `=` separates the whole key from the value; the value may contain
293
+ `=`. Inside quoted bracket strings, use `\\` to escape a quote or backslash.
294
+
295
+ If you set a shallow key to a string and later try to set a key under it
296
+ (`--var a=1` then `--var a.b=2`), `eksp` raises an error because an existing
297
+ string cannot become an object.
298
+
299
+ Optional `--var-root PATH` prepends `PATH.` to every `--var` key, with the same
300
+ rules as `--srcvar-root`.
301
+
302
+ ### Dots in property names
303
+
304
+ Namespaces are ordinary Python dicts from JSON (and string values from
305
+ `--srcvar` / `--var`). Dot notation always walks nested keys: `{{ SRC.db.host }}` is
306
+ `SRC["db"]["host"]`, not a single key named `"db.host"`.
307
+
308
+ If a JSON object uses a key that contains a dot (or other characters that
309
+ are awkward in attribute syntax), use bracket / subscript form — no extra
310
+ `eksp` escaping is required:
311
+
312
+ ```json
313
+ { "db.host": "postgres.internal", "url": "{{ SRC['db.host'] }}/app" }
314
+ ```
315
+
316
+ You can chain brackets and dots as needed, e.g. `{{ SRC['a.b'].port }}` when
317
+ `"a.b"` maps to an object with a `port` field.
318
+
319
+ 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.
320
+
321
+ ### Merge strategy for `SRC`
322
+
323
+ `SRC` is the shallow, last-wins merge of every `--src` document, in
324
+ the order they were specified on the command line, then patched by every
325
+ `--srcvar` (also last-wins at each leaf). Top-level keys from later
326
+ files completely replace top-level keys from earlier files (nested objects
327
+ are *not* merged recursively). If you need nested overrides, model them
328
+ explicitly inside a single config file.
329
+
330
+ You may pass `--src PATH` (no label): the file is only registered as `SRC1`,
331
+ `SRC2`, … in order. With `--src LABEL=PATH`, the file is registered under the
332
+ matching `SRC#` for that line and under `LABEL`. The first `--src LABEL=…` for a
333
+ 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
334
+ file’s snapshot).
335
+
336
+ Per-file snapshots remain under `SRC#` / `CTXT#`. Custom labels (for example
337
+ `R1`, `R2`, or repeated `parts`) hold the shallow merge of every CLI line that
338
+ used that label.
339
+
340
+ ### Context files (`--ctxt`)
341
+
342
+ `--ctxt` uses the same `[LABEL=]PATH` form as `--src`. Each file is loaded as a
343
+ JSON object, shallow-merged into `CTXT` (last-wins at the top level, like `SRC`),
344
+ and exposed as `CTXT1`, `CTXT2`, … in order, and under an optional custom label
345
+ (the same dict as the matching `CTXT#`). Templates may use `{{ CTXT.key }}` for
346
+ the merge or `{{ CTXT1.key }}` for a single file. Context is not merged
347
+ into `SRC` or the printed JSON unless `--src` content references it.
348
+
349
+ Namespace names from `--src` and `--ctxt` share one flat registry: the same custom
350
+ label cannot be used on both `--src` and `--ctxt`. You may repeat a label on the
351
+ same option (`--src R1=a.json --src R1=b.json`); files are shallow-merged into
352
+ that label namespace in order (like a JSON list on `$src.<LABEL>`). Each
353
+ `--src` / `--ctxt` invocation still gets its own `SRC#` / `CTXT#` slot pointing
354
+ at that file only. Implicit slot names `SRC#` are reserved for `--src` only and
355
+ `CTXT#` for `--ctxt` only. `CTXT` itself is reserved (like `SRC`) and cannot be
356
+ used as a `--ctxt` label.
357
+
358
+ ### Choosing CLI flags vs structural keys
359
+
360
+ Both mechanisms can load files and bind a namespace `LABEL`, but they hook in
361
+ at different stages and are not fully interchangeable.
362
+
363
+
364
+ | | `--src` / `--ctxt` | `$src.<LABEL>` / `$ctxt.<LABEL>` (root only) |
365
+ | ------------------------ | ----------------------------------------- | ---------------------------------------------------- |
366
+ | When files load | Before resolve (`build_namespaces`) | During resolve at the document root |
367
+ | Merges into `SRC` | Every `--src` file, always | No (only into printed output for `$src.`*) |
368
+ | Merges into printed JSON | Via resolving all of `SRC` | Shallow-merge **resolved** keys at root for `$src.`* |
369
+ | Merges into `CTXT` | Every `--ctxt` file | Only for `$ctxt.*` (raw shallow merge) |
370
+ | Several files, one label | Repeat `--src LABEL=…` / `--ctxt LABEL=…` | One key, value is a path string or list of paths |
371
+
372
+
373
+ **Practical rules:**
374
+
375
+ 1. Use `**--src` / `--ctxt`** when inputs are invocation-time (scripts, CI,
376
+ local paths) and should contribute to merged `SRC` / `CTXT`.
377
+ 2. Use `**$src.<LABEL>` / `$ctxt.<LABEL>`** when paths belong in the config
378
+ tree and you need root-level merge of a resolved subtree without listing every
379
+ file on the command line.
380
+ 3. Use **one binding per label per run**: if `LABEL` is already set (CLI,
381
+ `build_namespaces`, or a pre-filled entry in `namespaces`), matching
382
+ structural keys are **ignored with a warning** and their paths are **not**
383
+ loaded.
384
+ 4. Do not expect `--src frag=file.json` and `"$src.frag": "other.json"` to
385
+ combine; CLI wins and `other.json` is never read.
386
+
387
+ Bare `$src` (a list of paths, `$src` tree) is separate: it does not replace
388
+ labeled `--src` for building `SRC`.
389
+
390
+ ### Per-object resolution order
391
+
392
+ `resolve` walks the merged JSON recursively. For each object (at any depth),
393
+ `$include` / `$include.<name>` follow the order below.
394
+
395
+ At the resolve root (`$`) only, structural `$src` / `$ctxt` keys run first (nested
396
+ `$src`, `$src.<LABEL>`, or `$ctxt.<LABEL>` emit a warning and are ignored):
397
+
398
+ 1. `$ctxt.<LABEL>` (if any) — load path(s), bind namespace `LABEL`, shallow-merge
399
+ into `CTXT` (like `--ctxt LABEL=path`; not merged into printed `SRC`). Ignored
400
+ with a warning if `LABEL` was already bound (e.g. `--ctxt LABEL=path`).
401
+ 2. `$src.<LABEL>` (if any) — load path(s), bind namespace `LABEL`, shallow-merge
402
+ into the output (like `--src LABEL=path`). Ignored with a warning if `LABEL`
403
+ was already bound (e.g. `--src LABEL=path`).
404
+ 3. `$src` (if present) — shallow-merge listed files into the output via the
405
+ `$src` tree (see [$src flattening and //$src](#src-flattening)).
406
+
407
+ On every object (including nested):
408
+
409
+ 1. `$include` — dict or list of dicts, shallow-merged in order.
410
+ 2. `$include.<name>` — one value or merged fragment list.
411
+ 3. Ordinary keys — last; win on name clashes.
412
+
413
+ Throughout, every string is rendered with Jinja to a fixed point when that
414
+ value is visited (including path strings in `$src` / `$ctxt` bindings).
415
+
416
+ #### $src flattening and `//$src`
417
+
418
+ When building the merge list for a given object’s `$src`, `eksp` loads
419
+ each file’s top-level JSON object and follows only that file’s top-level
420
+ `$src` to walk the dependency tree (deduplicated, depth-first). With
421
+ `--debug`, `//$src` on that same object lists those flattened paths.
422
+
423
+ A nested object with `$src` or `$src.<LABEL>` is ignored with a warning.
424
+ Files loaded by a root-level `$src` may still declare their own
425
+ top-level `$src` lists (chain behavior).
426
+
427
+ ## `$include` and `--debug`
428
+
429
+ Inside any JSON object you may use:
430
+
431
+ - `$include` — a **namespace reference** string (e.g. `"SRC.fragments"` or
432
+ `"FRAG.defaults"`), a JSON object, or a JSON array of references/objects.
433
+ Reference strings use the same path rules as Jinja (`SRC.db.host`,
434
+ `SRC['db.host']`, `CTXT1.items`, etc.) but without `{{ }}`. If the value
435
+ contains `{{ ... }}`, it is rendered first and must become a bare reference
436
+ string (not an inlined dict/list). Included dicts are
437
+ shallow-merged in order after any `$src` on the same object (see
438
+ [Per-object resolution order](#per-object-resolution-order)); later entries win
439
+ on top-level key clashes. Example: `"$include": ["FRAG1", "FRAG2", {"extra": 1}]`.
440
+ - `$include.<name>` — e.g. `"$include.options": "SRC.defaults"`. Same reference
441
+ rules as bare `$include`. A JSON array is a fragment list (each entry may be a
442
+ reference string, object, or list). Only homogeneous fragment lists are allowed
443
+ (all dicts shallow-merged, or all lists concatenated). A single reference that
444
+ resolves to one list (e.g. `"$include.tags": "ENV.TAGS"`) is one value, not a
445
+ fragment list. Whole-value templates like `"{{ ENV.TAGS }}"` are not supported
446
+ here (use `"ENV.TAGS"` or `"{{ ENV.TAGS_KEY }}"` when the key holds a path).
447
+
448
+ Relative to any `$src` on the same object, processing follows
449
+ [Per-object resolution order](#per-object-resolution-order) above: bare `$include`, then each `$include.<name>`, then
450
+ ordinary keys.
451
+
452
+ ### Equivalence
453
+
454
+ A single template that evaluates to a list of dicts is the same as writing that
455
+ list inline. If `SRC.fragments` is:
456
+
457
+ ```json
458
+ [
459
+ {"host": "{{ ENV.DB_HOST }}", "port": 5432},
460
+ {"host": "backup.example.com", "ssl": true}
461
+ ]
462
+ ```
463
+
464
+ then these forms are equivalent:
465
+
466
+ ```json
467
+ "$include": "SRC.fragments"
468
+ ```
469
+
470
+ matches an inline list:
471
+
472
+ ```json
473
+ "$include": [
474
+ {"host": "{{ ENV.DB_HOST }}", "port": 5432},
475
+ {"host": "backup.example.com", "ssl": true}
476
+ ]
477
+ ```
478
+
479
+ Each fragment is resolved and shallow-merged in order; later fragments win on
480
+ duplicate top-level keys; ordinary keys on the same object still run last. The
481
+ template form is useful when the fragment list lives in a namespace (from
482
+ `--src`, `--var`, another file, etc.) instead of being duplicated in the config.
483
+
484
+ If the expression evaluates to a single dict, that is treated as a one-item
485
+ include (same as before). If it evaluates to a list whose items are not dicts
486
+ (after any per-item template evaluation), resolution fails with an error at
487
+ `$.$include[n]`.
488
+
489
+ ### `--debug`
490
+
491
+ When the `--debug` flag is set, objects that used `$include` or `$include.<name>` also
492
+ emit `//$include` and `//$include.<name>` keys with the raw input values
493
+ (e.g. the template string before evaluation). Objects that used `$src` also
494
+ emit `//$src` (see [$src flattening and //$src](#src-flattening)).
495
+
496
+ ## `$src` and `$ctxt`
497
+
498
+ Only on the **resolve root** (`$`, i.e. the document passed to `resolve` — merged
499
+ `SRC` for the CLI). Nested `$src`, `$src.<LABEL>`, or `$ctxt.<LABEL>` keys
500
+ are ignored with a warning.
501
+
502
+ ### `$src` — merge into output
503
+
504
+ ```json
505
+ {
506
+ "$src": ["./base.json", "{{ ENV.CONFIG_DIR }}/more.json"],
507
+ "service": "api"
508
+ }
509
+ ```
510
+
511
+ - Value must be a list of path strings.
512
+ - Files are shallow-merged into the output via the `$src` tree (see
513
+ [$src flattening and //$src](#src-flattening)).
514
+ - Loaded files may declare their own top-level `$src` lists (chains).
515
+
516
+ ### `$src.<LABEL>` — merge into output and namespace
517
+
518
+ ```json
519
+ {
520
+ "$src.fragments": "./fragments.json",
521
+ "$src.defaults": ["./defaults.json", "./overrides.json"],
522
+ "items": "{{ fragments.items }}"
523
+ }
524
+ ```
525
+
526
+ Similar to repeating `--src LABEL=path` for the **label namespace** and a
527
+ root-level merge, but **not** the same as `--src`: every `--src` file still
528
+ feeds merged `SRC`; `$src.<LABEL>` only shallow-merges a **resolved** subtree
529
+ into the printed output at root (last-wins at the top level) and binds
530
+ `{{ LABEL }}`. The raw document (before subtree resolution) is kept on the label
531
+ namespace. If `LABEL` was already bound (CLI or caller), this key is ignored
532
+ and the path(s) are not loaded.
533
+
534
+ ### `$ctxt.<LABEL>` — context only (not in printed `SRC`)
535
+
536
+ ```json
537
+ {
538
+ "$ctxt.side": "./side.json",
539
+ "rollout": "{{ side.flag }}"
540
+ }
541
+ ```
542
+
543
+ Similar to `--ctxt LABEL=path` for **CTXT** and `{{ LABEL }}`, but only when
544
+ `LABEL` is not already bound; otherwise ignored (paths not loaded). Does not add
545
+ keys to the printed output unless a template references them.
546
+
547
+ ### Shared rules
548
+
549
+ - Path value: one string or a list of strings (lists shallow-merge, last-wins).
550
+ - `LABEL` naming matches `--src` / `--ctxt` (see Namespaces).
551
+ - If `LABEL` is already bound (CLI, `build_namespaces`, or a pre-filled
552
+ `namespaces` entry), the structural key is ignored with a warning and its
553
+ path(s) are not read.
554
+ - Structural `$src` / `$ctxt` keys are stripped from loaded files before binding.
555
+ - Paths may use Jinja `{{ ... }}`; relative paths use the resolve root directory.
556
+ - Ordinary keys on the root object still override merged `$src` keys.
557
+
558
+ ## Recursive resolution
559
+
560
+ For each string value `eksp` walks, the Jinja environment is invoked
561
+ repeatedly until the rendered text equals the input text. That allows chains
562
+ like:
563
+
564
+ ```json
565
+ { "a": "{{ SRC.b }}", "b": "{{ SRC.c }}", "c": "deepest" }
566
+ ```
567
+
568
+ to resolve all the way down to `"deepest"` while still terminating cleanly on
569
+ values that have no template markers (they render to themselves on the first
570
+ pass).
571
+
572
+ If a string is only one `{{ ... }}` expression (optional whitespace), the
573
+ result keeps the expression’s native type: `{"cfg": "{{ SRC.cfg }}"}` can
574
+ become a JSON object, not a serialized string. Text that mixes literals and
575
+ templates (e.g. `"host={{ SRC.db.host }}"`) still becomes a string.
576
+
577
+ A safety cap (100 iterations) is applied to surface cyclic references as a
578
+ clear error rather than hanging.
579
+
580
+ Non-string scalars (`int`, `float`, `bool`, `null`) in the input JSON pass
581
+ through untouched when they are not inside a template string.
582
+
583
+ ## Development
584
+
585
+ ```bash
586
+ uv sync # install runtime + dev dependencies
587
+ uv run pytest # run the test suite
588
+ uv run pytest --cov=eksp # run with coverage
589
+ uv run ruff check . # lint
590
+ uv run ruff format . # format
591
+ ```
592
+
593
+ The project uses a `src/` layout, `pytest` for tests, and `ruff` for
594
+ lint+format. Tests live in `tests/`, with JSON fixtures under
595
+ `tests/fixtures/`.
596
+
597
+ ### Releasing
598
+
599
+ Set `version` in `pyproject.toml`, then build and publish with [uv](https://docs.astral.sh/uv/).
600
+ Always remove `dist/` before building so `uv publish` (which uploads `dist/*` by
601
+ default) does not upload stale wheels from earlier versions.
602
+
603
+ ```bash
604
+ uv run pytest -q # optional check before release
605
+ rm -rf dist/ build/
606
+ uv build # dist/eksp-<version>-py3-none-any.whl + .tar.gz
607
+ ls dist/
608
+
609
+ # TestPyPI
610
+ UV_PUBLISH_TOKEN=pypi-... \
611
+ uv publish --publish-url https://test.pypi.org/legacy/
612
+
613
+ # Production PyPI
614
+ UV_PUBLISH_TOKEN=pypi-... \
615
+ uv publish
616
+
617
+ # Optional: skip files already on the index
618
+ # uv publish --check-url https://pypi.org/simple/
619
+ # uv publish --dry-run
620
+ ```
621
+
622
+ Create the token at [pypi.org](https://pypi.org/manage/account/token/) or
623
+ [TestPyPI](https://test.pypi.org/manage/account/token/). To install from
624
+ TestPyPI (dependencies still come from PyPI):
625
+
626
+ ```bash
627
+ uv pip install --index-url https://test.pypi.org/simple/ \
628
+ --extra-index-url https://pypi.org/simple/ \
629
+ eksp==<version>
630
+ ```
631
+
632
+ Old files under `dist/` are local-only (gitignored) and safe to delete with
633
+ `rm -rf dist/`. Cleaning TestPyPI releases is done on the TestPyPI website;
634
+ production PyPI releases cannot be deleted—yank unwanted versions on the project
635
+ page if needed.
636
+
637
+ ## License
638
+
639
+ MIT. See [LICENSE](LICENSE).