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 +30 -0
- eksp-0.9.0/.python-version +1 -0
- eksp-0.9.0/LICENSE +21 -0
- eksp-0.9.0/PKG-INFO +639 -0
- eksp-0.9.0/README.md +613 -0
- eksp-0.9.0/pyproject.toml +63 -0
- eksp-0.9.0/sample/README.md +62 -0
- eksp-0.9.0/sample/import-sample/01-common.json +10 -0
- eksp-0.9.0/sample/import-sample/02-middle.json +7 -0
- eksp-0.9.0/sample/import-sample/03-main.json +8 -0
- eksp-0.9.0/sample/include-sample/01-base.json +13 -0
- eksp-0.9.0/sample/include-sample/02-layer.json +7 -0
- eksp-0.9.0/sample/include-sample/03-overrides.json +7 -0
- eksp-0.9.0/src/eksp/__init__.py +61 -0
- eksp-0.9.0/src/eksp/__main__.py +6 -0
- eksp-0.9.0/src/eksp/api.py +92 -0
- eksp-0.9.0/src/eksp/cli.py +180 -0
- eksp-0.9.0/src/eksp/json_format.py +18 -0
- eksp-0.9.0/src/eksp/namespaces.py +606 -0
- eksp-0.9.0/src/eksp/pipeline.py +80 -0
- eksp-0.9.0/src/eksp/resolver.py +1066 -0
- eksp-0.9.0/src/eksp/sample.py +192 -0
- eksp-0.9.0/tests/__init__.py +0 -0
- eksp-0.9.0/tests/fixtures/cfg1.json +10 -0
- eksp-0.9.0/tests/fixtures/cfg2.json +11 -0
- eksp-0.9.0/tests/test_api.py +98 -0
- eksp-0.9.0/tests/test_cli.py +462 -0
- eksp-0.9.0/tests/test_namespaces.py +376 -0
- eksp-0.9.0/tests/test_resolver.py +511 -0
- eksp-0.9.0/tests/test_sample.py +109 -0
- eksp-0.9.0/uv.lock +436 -0
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).
|