jsonx-cli 0.1.0__py3-none-any.whl
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.
- jsonx/__init__.py +3 -0
- jsonx/cli.py +522 -0
- jsonx/path.py +445 -0
- jsonx_cli-0.1.0.dist-info/METADATA +228 -0
- jsonx_cli-0.1.0.dist-info/RECORD +8 -0
- jsonx_cli-0.1.0.dist-info/WHEEL +4 -0
- jsonx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- jsonx_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
jsonx/__init__.py
ADDED
jsonx/cli.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""jsonx CLI - JSON query and transform tool."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .path import (
|
|
11
|
+
get_value, set_value, delete_value, flatten, unflatten,
|
|
12
|
+
keys, merge, diff
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_json(source: Optional[str]) -> dict:
|
|
17
|
+
"""Load JSON from file or stdin."""
|
|
18
|
+
if source is None or source == '-':
|
|
19
|
+
data = sys.stdin.read()
|
|
20
|
+
else:
|
|
21
|
+
with open(source, 'r') as f:
|
|
22
|
+
data = f.read()
|
|
23
|
+
|
|
24
|
+
return json.loads(data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def output(data, json_output: bool = False, pretty: bool = True):
|
|
28
|
+
"""Output data appropriately."""
|
|
29
|
+
if isinstance(data, (dict, list)):
|
|
30
|
+
if pretty:
|
|
31
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
32
|
+
else:
|
|
33
|
+
click.echo(json.dumps(data, ensure_ascii=False))
|
|
34
|
+
elif isinstance(data, bool):
|
|
35
|
+
# JSON booleans are lowercase
|
|
36
|
+
click.echo(json.dumps(data))
|
|
37
|
+
elif data is None:
|
|
38
|
+
click.echo("null")
|
|
39
|
+
elif json_output:
|
|
40
|
+
click.echo(json.dumps(data, ensure_ascii=False))
|
|
41
|
+
else:
|
|
42
|
+
click.echo(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.group()
|
|
46
|
+
@click.version_option(version=__version__)
|
|
47
|
+
def main():
|
|
48
|
+
"""JSON query and transform CLI - simpler than jq.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
|
|
52
|
+
# Get nested value
|
|
53
|
+
jsonx get .users[0].name data.json
|
|
54
|
+
|
|
55
|
+
# Query from stdin
|
|
56
|
+
cat data.json | jsonx get .config.debug
|
|
57
|
+
|
|
58
|
+
# Set value and output
|
|
59
|
+
jsonx set .config.debug true data.json
|
|
60
|
+
|
|
61
|
+
# List keys
|
|
62
|
+
jsonx keys data.json
|
|
63
|
+
|
|
64
|
+
# Flatten nested structure
|
|
65
|
+
jsonx flatten data.json
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@main.command()
|
|
71
|
+
@click.argument('path', default='.')
|
|
72
|
+
@click.argument('source', required=False)
|
|
73
|
+
@click.option('--json', 'json_out', is_flag=True, help='Force JSON output')
|
|
74
|
+
@click.option('--raw', '-r', is_flag=True, help='Raw output (no quotes for strings)')
|
|
75
|
+
def get(path: str, source: Optional[str], json_out: bool, raw: bool):
|
|
76
|
+
"""Get value at path.
|
|
77
|
+
|
|
78
|
+
PATH uses dot notation:
|
|
79
|
+
|
|
80
|
+
.key - object key
|
|
81
|
+
.key.nested - nested key
|
|
82
|
+
[0] - array index
|
|
83
|
+
[*] - all array items
|
|
84
|
+
[0:3] - array slice
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
|
|
88
|
+
jsonx get .name data.json
|
|
89
|
+
jsonx get .users[0].email data.json
|
|
90
|
+
jsonx get .users[*].name data.json
|
|
91
|
+
cat data.json | jsonx get .config
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
data = load_json(source)
|
|
95
|
+
result = get_value(data, path)
|
|
96
|
+
|
|
97
|
+
if raw and isinstance(result, str):
|
|
98
|
+
click.echo(result)
|
|
99
|
+
else:
|
|
100
|
+
output(result, json_out)
|
|
101
|
+
|
|
102
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
103
|
+
click.echo(f"Error: {e}", err=True)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
except json.JSONDecodeError as e:
|
|
106
|
+
click.echo(f"Invalid JSON: {e}", err=True)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
click.echo(f"File not found: {source}", err=True)
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command()
|
|
114
|
+
@click.argument('path')
|
|
115
|
+
@click.argument('value')
|
|
116
|
+
@click.argument('source', required=False)
|
|
117
|
+
@click.option('--in-place', '-i', is_flag=True, help='Modify file in place')
|
|
118
|
+
@click.option('--output', '-o', 'outfile', help='Write to file')
|
|
119
|
+
@click.option('--compact', '-c', is_flag=True, help='Compact output')
|
|
120
|
+
def set(path: str, value: str, source: Optional[str], in_place: bool, outfile: Optional[str], compact: bool):
|
|
121
|
+
"""Set value at path.
|
|
122
|
+
|
|
123
|
+
VALUE is parsed as JSON. Use quotes for strings.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
|
|
127
|
+
jsonx set .debug true data.json
|
|
128
|
+
jsonx set .name '"Alice"' data.json
|
|
129
|
+
jsonx set .tags '["a","b"]' data.json
|
|
130
|
+
jsonx set .config.timeout 30 data.json -i
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
data = load_json(source)
|
|
134
|
+
|
|
135
|
+
# Parse value as JSON
|
|
136
|
+
try:
|
|
137
|
+
parsed_value = json.loads(value)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
# Treat as string if not valid JSON
|
|
140
|
+
parsed_value = value
|
|
141
|
+
|
|
142
|
+
result = set_value(data, path, parsed_value)
|
|
143
|
+
|
|
144
|
+
if in_place and source and source != '-':
|
|
145
|
+
with open(source, 'w') as f:
|
|
146
|
+
json.dump(result, f, indent=2 if not compact else None, ensure_ascii=False)
|
|
147
|
+
f.write('\n')
|
|
148
|
+
elif outfile:
|
|
149
|
+
with open(outfile, 'w') as f:
|
|
150
|
+
json.dump(result, f, indent=2 if not compact else None, ensure_ascii=False)
|
|
151
|
+
f.write('\n')
|
|
152
|
+
else:
|
|
153
|
+
output(result, pretty=not compact)
|
|
154
|
+
|
|
155
|
+
except (KeyError, TypeError) as e:
|
|
156
|
+
click.echo(f"Error: {e}", err=True)
|
|
157
|
+
sys.exit(1)
|
|
158
|
+
except json.JSONDecodeError as e:
|
|
159
|
+
click.echo(f"Invalid JSON: {e}", err=True)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@main.command('del')
|
|
164
|
+
@click.argument('path')
|
|
165
|
+
@click.argument('source', required=False)
|
|
166
|
+
@click.option('--in-place', '-i', is_flag=True, help='Modify file in place')
|
|
167
|
+
@click.option('--output', '-o', 'outfile', help='Write to file')
|
|
168
|
+
def delete(path: str, source: Optional[str], in_place: bool, outfile: Optional[str]):
|
|
169
|
+
"""Delete value at path.
|
|
170
|
+
|
|
171
|
+
Examples:
|
|
172
|
+
|
|
173
|
+
jsonx del .temp data.json
|
|
174
|
+
jsonx del .users[0] data.json -i
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
data = load_json(source)
|
|
178
|
+
result = delete_value(data, path)
|
|
179
|
+
|
|
180
|
+
if in_place and source and source != '-':
|
|
181
|
+
with open(source, 'w') as f:
|
|
182
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
183
|
+
f.write('\n')
|
|
184
|
+
elif outfile:
|
|
185
|
+
with open(outfile, 'w') as f:
|
|
186
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
187
|
+
f.write('\n')
|
|
188
|
+
else:
|
|
189
|
+
output(result)
|
|
190
|
+
|
|
191
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
192
|
+
click.echo(f"Error: {e}", err=True)
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@main.command()
|
|
197
|
+
@click.argument('source', required=False)
|
|
198
|
+
@click.option('--recursive', '-r', is_flag=True, help='Show all nested keys with paths')
|
|
199
|
+
@click.option('--json', 'json_out', is_flag=True, help='JSON output')
|
|
200
|
+
def keys(source: Optional[str], recursive: bool, json_out: bool):
|
|
201
|
+
"""List keys in JSON object.
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
|
|
205
|
+
jsonx keys data.json
|
|
206
|
+
jsonx keys data.json -r # All nested keys
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
from .path import keys as get_keys
|
|
210
|
+
|
|
211
|
+
data = load_json(source)
|
|
212
|
+
result = get_keys(data, recursive=recursive)
|
|
213
|
+
|
|
214
|
+
if json_out:
|
|
215
|
+
click.echo(json.dumps(result, indent=2))
|
|
216
|
+
else:
|
|
217
|
+
for key in result:
|
|
218
|
+
click.echo(key)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
click.echo(f"Error: {e}", err=True)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@main.command()
|
|
226
|
+
@click.argument('source', required=False)
|
|
227
|
+
@click.option('--sep', default='.', help='Key separator')
|
|
228
|
+
def flatten(source: Optional[str], sep: str):
|
|
229
|
+
"""Flatten nested JSON to dot-notation keys.
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
|
|
233
|
+
jsonx flatten data.json
|
|
234
|
+
# {"users[0].name": "Alice", "config.debug": true}
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
from .path import flatten as do_flatten
|
|
238
|
+
|
|
239
|
+
data = load_json(source)
|
|
240
|
+
result = do_flatten(data, sep=sep)
|
|
241
|
+
output(result)
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
click.echo(f"Error: {e}", err=True)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@main.command()
|
|
249
|
+
@click.argument('source', required=False)
|
|
250
|
+
@click.option('--sep', default='.', help='Key separator')
|
|
251
|
+
def unflatten(source: Optional[str], sep: str):
|
|
252
|
+
"""Unflatten dot-notation keys to nested structure.
|
|
253
|
+
|
|
254
|
+
Examples:
|
|
255
|
+
|
|
256
|
+
echo '{"user.name": "Alice"}' | jsonx unflatten
|
|
257
|
+
# {"user": {"name": "Alice"}}
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
from .path import unflatten as do_unflatten
|
|
261
|
+
|
|
262
|
+
data = load_json(source)
|
|
263
|
+
result = do_unflatten(data, sep=sep)
|
|
264
|
+
output(result)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
click.echo(f"Error: {e}", err=True)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@main.command()
|
|
272
|
+
@click.argument('files', nargs=-1, required=True)
|
|
273
|
+
@click.option('--deep/--shallow', default=True, help='Deep merge nested objects')
|
|
274
|
+
def merge(files: tuple, deep: bool):
|
|
275
|
+
"""Merge multiple JSON files.
|
|
276
|
+
|
|
277
|
+
Later files override earlier ones.
|
|
278
|
+
|
|
279
|
+
Examples:
|
|
280
|
+
|
|
281
|
+
jsonx merge base.json override.json
|
|
282
|
+
jsonx merge a.json b.json c.json
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
from .path import merge as do_merge
|
|
286
|
+
|
|
287
|
+
if len(files) < 2:
|
|
288
|
+
click.echo("Need at least 2 files to merge", err=True)
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
|
|
291
|
+
result = load_json(files[0])
|
|
292
|
+
for f in files[1:]:
|
|
293
|
+
overlay = load_json(f)
|
|
294
|
+
result = do_merge(result, overlay, deep=deep)
|
|
295
|
+
|
|
296
|
+
output(result)
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
click.echo(f"Error: {e}", err=True)
|
|
300
|
+
sys.exit(1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@main.command()
|
|
304
|
+
@click.argument('file1')
|
|
305
|
+
@click.argument('file2')
|
|
306
|
+
@click.option('--json', 'json_out', is_flag=True, help='JSON output')
|
|
307
|
+
def diff(file1: str, file2: str, json_out: bool):
|
|
308
|
+
"""Show differences between two JSON files.
|
|
309
|
+
|
|
310
|
+
Examples:
|
|
311
|
+
|
|
312
|
+
jsonx diff old.json new.json
|
|
313
|
+
"""
|
|
314
|
+
try:
|
|
315
|
+
from .path import diff as do_diff
|
|
316
|
+
|
|
317
|
+
a = load_json(file1)
|
|
318
|
+
b = load_json(file2)
|
|
319
|
+
differences = do_diff(a, b)
|
|
320
|
+
|
|
321
|
+
if not differences:
|
|
322
|
+
click.echo("No differences")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if json_out:
|
|
326
|
+
click.echo(json.dumps(differences, indent=2))
|
|
327
|
+
else:
|
|
328
|
+
for d in differences:
|
|
329
|
+
path = d['path']
|
|
330
|
+
dtype = d['type']
|
|
331
|
+
|
|
332
|
+
if dtype == 'added':
|
|
333
|
+
click.echo(click.style(f"+ {path}: {json.dumps(d['value'])}", fg='green'))
|
|
334
|
+
elif dtype == 'removed':
|
|
335
|
+
click.echo(click.style(f"- {path}: {json.dumps(d['value'])}", fg='red'))
|
|
336
|
+
elif dtype == 'changed':
|
|
337
|
+
click.echo(click.style(f"~ {path}: {json.dumps(d['old'])} → {json.dumps(d['new'])}", fg='yellow'))
|
|
338
|
+
elif dtype == 'type_change':
|
|
339
|
+
click.echo(click.style(f"! {path}: type changed {json.dumps(d['old'])} → {json.dumps(d['new'])}", fg='magenta'))
|
|
340
|
+
|
|
341
|
+
except Exception as e:
|
|
342
|
+
click.echo(f"Error: {e}", err=True)
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@main.command()
|
|
347
|
+
@click.argument('source', required=False)
|
|
348
|
+
@click.option('--indent', '-i', default=2, type=int, help='Indentation level')
|
|
349
|
+
@click.option('--sort-keys', '-s', is_flag=True, help='Sort object keys')
|
|
350
|
+
def format(source: Optional[str], indent: int, sort_keys: bool):
|
|
351
|
+
"""Pretty print JSON.
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
|
|
355
|
+
jsonx format data.json
|
|
356
|
+
cat data.json | jsonx format --indent 4
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
data = load_json(source)
|
|
360
|
+
click.echo(json.dumps(data, indent=indent, sort_keys=sort_keys, ensure_ascii=False))
|
|
361
|
+
|
|
362
|
+
except Exception as e:
|
|
363
|
+
click.echo(f"Error: {e}", err=True)
|
|
364
|
+
sys.exit(1)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@main.command()
|
|
368
|
+
@click.argument('source', required=False)
|
|
369
|
+
def minify(source: Optional[str]):
|
|
370
|
+
"""Minify JSON (compact format).
|
|
371
|
+
|
|
372
|
+
Examples:
|
|
373
|
+
|
|
374
|
+
jsonx minify data.json
|
|
375
|
+
jsonx minify data.json > data.min.json
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
data = load_json(source)
|
|
379
|
+
click.echo(json.dumps(data, separators=(',', ':'), ensure_ascii=False))
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
click.echo(f"Error: {e}", err=True)
|
|
383
|
+
sys.exit(1)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@main.command()
|
|
387
|
+
@click.argument('source', required=False)
|
|
388
|
+
@click.argument('schema')
|
|
389
|
+
def validate(source: Optional[str], schema: str):
|
|
390
|
+
"""Validate JSON against a JSON Schema.
|
|
391
|
+
|
|
392
|
+
Requires: pip install jsonx-cli[schema]
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
|
|
396
|
+
jsonx validate data.json schema.json
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
import jsonschema
|
|
400
|
+
except ImportError:
|
|
401
|
+
click.echo("jsonschema not installed. Run: pip install jsonx-cli[schema]", err=True)
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
data = load_json(source)
|
|
406
|
+
schema_data = load_json(schema)
|
|
407
|
+
|
|
408
|
+
jsonschema.validate(data, schema_data)
|
|
409
|
+
click.echo(click.style("✓ Valid", fg='green'))
|
|
410
|
+
|
|
411
|
+
except jsonschema.ValidationError as e:
|
|
412
|
+
click.echo(click.style(f"✗ Invalid: {e.message}", fg='red'), err=True)
|
|
413
|
+
click.echo(f" Path: {'.'.join(str(p) for p in e.absolute_path)}", err=True)
|
|
414
|
+
sys.exit(1)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
click.echo(f"Error: {e}", err=True)
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@main.command()
|
|
421
|
+
@click.argument('source', required=False)
|
|
422
|
+
def type(source: Optional[str]):
|
|
423
|
+
"""Show the type of JSON value.
|
|
424
|
+
|
|
425
|
+
Examples:
|
|
426
|
+
|
|
427
|
+
echo '[]' | jsonx type # array
|
|
428
|
+
echo '{}' | jsonx type # object
|
|
429
|
+
echo '42' | jsonx type # number
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
data = load_json(source)
|
|
433
|
+
|
|
434
|
+
if isinstance(data, dict):
|
|
435
|
+
click.echo("object")
|
|
436
|
+
elif isinstance(data, list):
|
|
437
|
+
click.echo("array")
|
|
438
|
+
elif isinstance(data, str):
|
|
439
|
+
click.echo("string")
|
|
440
|
+
elif isinstance(data, bool):
|
|
441
|
+
click.echo("boolean")
|
|
442
|
+
elif isinstance(data, int):
|
|
443
|
+
click.echo("integer")
|
|
444
|
+
elif isinstance(data, float):
|
|
445
|
+
click.echo("number")
|
|
446
|
+
elif data is None:
|
|
447
|
+
click.echo("null")
|
|
448
|
+
else:
|
|
449
|
+
click.echo("unknown")
|
|
450
|
+
|
|
451
|
+
except Exception as e:
|
|
452
|
+
click.echo(f"Error: {e}", err=True)
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@main.command()
|
|
457
|
+
@click.argument('source', required=False)
|
|
458
|
+
def count(source: Optional[str]):
|
|
459
|
+
"""Count items in array or keys in object.
|
|
460
|
+
|
|
461
|
+
Examples:
|
|
462
|
+
|
|
463
|
+
jsonx count data.json # Count top-level
|
|
464
|
+
jsonx get .users data.json | jsonx count # Count users
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
data = load_json(source)
|
|
468
|
+
|
|
469
|
+
if isinstance(data, (list, dict)):
|
|
470
|
+
click.echo(len(data))
|
|
471
|
+
else:
|
|
472
|
+
click.echo("1")
|
|
473
|
+
|
|
474
|
+
except Exception as e:
|
|
475
|
+
click.echo(f"Error: {e}", err=True)
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@main.command('each')
|
|
480
|
+
@click.argument('source', required=False)
|
|
481
|
+
@click.option('--path', '-p', default='.', help='Path to array')
|
|
482
|
+
@click.option('--template', '-t', help='Output template (use {.key} for values)')
|
|
483
|
+
def each(source: Optional[str], path: str, template: Optional[str]):
|
|
484
|
+
"""Iterate over array items.
|
|
485
|
+
|
|
486
|
+
Examples:
|
|
487
|
+
|
|
488
|
+
jsonx each data.json -p .users -t '{.name}: {.email}'
|
|
489
|
+
jsonx get .items data.json | jsonx each -t '{.id}'
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
data = load_json(source)
|
|
493
|
+
items = get_value(data, path)
|
|
494
|
+
|
|
495
|
+
if not isinstance(items, list):
|
|
496
|
+
click.echo("Path must point to an array", err=True)
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
|
|
499
|
+
for item in items:
|
|
500
|
+
if template:
|
|
501
|
+
# Simple template expansion
|
|
502
|
+
result = template
|
|
503
|
+
# Find all {.path} patterns
|
|
504
|
+
import re
|
|
505
|
+
for match in re.finditer(r'\{(\.[^}]+)\}', template):
|
|
506
|
+
item_path = match.group(1)
|
|
507
|
+
try:
|
|
508
|
+
val = get_value(item, item_path)
|
|
509
|
+
result = result.replace(match.group(0), str(val))
|
|
510
|
+
except (KeyError, TypeError):
|
|
511
|
+
result = result.replace(match.group(0), '')
|
|
512
|
+
click.echo(result)
|
|
513
|
+
else:
|
|
514
|
+
click.echo(json.dumps(item, ensure_ascii=False))
|
|
515
|
+
|
|
516
|
+
except Exception as e:
|
|
517
|
+
click.echo(f"Error: {e}", err=True)
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
if __name__ == '__main__':
|
|
522
|
+
main()
|
jsonx/path.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""JSON path query engine with simple dot notation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, List, Tuple, Union
|
|
5
|
+
|
|
6
|
+
# Path segment patterns
|
|
7
|
+
ARRAY_INDEX = re.compile(r'\[(-?\d+)\]')
|
|
8
|
+
ARRAY_SLICE = re.compile(r'\[(\d*):(\d*)\]')
|
|
9
|
+
ARRAY_WILDCARD = re.compile(r'\[\*\]')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_path(path: str) -> List[Union[str, int, Tuple[int, int], None]]:
|
|
13
|
+
"""
|
|
14
|
+
Parse a dot-notation path into segments.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
".users[0].name" -> ["users", 0, "name"]
|
|
18
|
+
".data[*].id" -> ["data", None, "id"] (None = wildcard)
|
|
19
|
+
".items[1:3]" -> ["items", (1, 3)]
|
|
20
|
+
"""
|
|
21
|
+
if not path or path == '.':
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
# Remove leading dot
|
|
25
|
+
if path.startswith('.'):
|
|
26
|
+
path = path[1:]
|
|
27
|
+
|
|
28
|
+
segments = []
|
|
29
|
+
current = ""
|
|
30
|
+
i = 0
|
|
31
|
+
|
|
32
|
+
while i < len(path):
|
|
33
|
+
char = path[i]
|
|
34
|
+
|
|
35
|
+
if char == '.':
|
|
36
|
+
if current:
|
|
37
|
+
segments.append(current)
|
|
38
|
+
current = ""
|
|
39
|
+
i += 1
|
|
40
|
+
elif char == '[':
|
|
41
|
+
if current:
|
|
42
|
+
segments.append(current)
|
|
43
|
+
current = ""
|
|
44
|
+
|
|
45
|
+
# Find matching bracket
|
|
46
|
+
end = path.find(']', i)
|
|
47
|
+
if end == -1:
|
|
48
|
+
raise ValueError(f"Unclosed bracket in path: {path}")
|
|
49
|
+
|
|
50
|
+
bracket_content = path[i:end+1]
|
|
51
|
+
|
|
52
|
+
# Check for wildcard
|
|
53
|
+
if bracket_content == '[*]':
|
|
54
|
+
segments.append(None) # None represents wildcard
|
|
55
|
+
# Check for slice
|
|
56
|
+
elif ':' in bracket_content:
|
|
57
|
+
match = ARRAY_SLICE.match(bracket_content)
|
|
58
|
+
if match:
|
|
59
|
+
start = int(match.group(1)) if match.group(1) else 0
|
|
60
|
+
end_idx = int(match.group(2)) if match.group(2) else None
|
|
61
|
+
segments.append((start, end_idx))
|
|
62
|
+
# Check for index
|
|
63
|
+
else:
|
|
64
|
+
match = ARRAY_INDEX.match(bracket_content)
|
|
65
|
+
if match:
|
|
66
|
+
segments.append(int(match.group(1)))
|
|
67
|
+
else:
|
|
68
|
+
# Treat as string key (for object keys with special chars)
|
|
69
|
+
key = bracket_content[1:-1].strip('"\'')
|
|
70
|
+
segments.append(key)
|
|
71
|
+
|
|
72
|
+
i = end + 1
|
|
73
|
+
else:
|
|
74
|
+
current += char
|
|
75
|
+
i += 1
|
|
76
|
+
|
|
77
|
+
if current:
|
|
78
|
+
segments.append(current)
|
|
79
|
+
|
|
80
|
+
return segments
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_value(data: Any, path: str) -> Any:
|
|
84
|
+
"""
|
|
85
|
+
Get value at path from JSON data.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
data: JSON data (dict, list, or primitive)
|
|
89
|
+
path: Dot-notation path (e.g., ".users[0].name")
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Value at path, or list of values for wildcards
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
KeyError: If path doesn't exist
|
|
96
|
+
IndexError: If array index out of bounds
|
|
97
|
+
"""
|
|
98
|
+
if not path or path == '.':
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
segments = parse_path(path)
|
|
102
|
+
return _get_by_segments(data, segments)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_by_segments(data: Any, segments: List) -> Any:
|
|
106
|
+
"""Navigate data using parsed segments."""
|
|
107
|
+
if not segments:
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
current = data
|
|
111
|
+
remaining_segments = list(segments)
|
|
112
|
+
|
|
113
|
+
while remaining_segments:
|
|
114
|
+
segment = remaining_segments.pop(0)
|
|
115
|
+
|
|
116
|
+
if segment is None:
|
|
117
|
+
# Wildcard - apply remaining path to each element
|
|
118
|
+
if not isinstance(current, list):
|
|
119
|
+
raise TypeError(f"Wildcard [*] requires array, got {type(current).__name__}")
|
|
120
|
+
|
|
121
|
+
if not remaining_segments:
|
|
122
|
+
return current
|
|
123
|
+
|
|
124
|
+
return [_get_by_segments(item, remaining_segments) for item in current]
|
|
125
|
+
|
|
126
|
+
elif isinstance(segment, tuple):
|
|
127
|
+
# Slice
|
|
128
|
+
if not isinstance(current, list):
|
|
129
|
+
raise TypeError(f"Slice requires array, got {type(current).__name__}")
|
|
130
|
+
|
|
131
|
+
start, end = segment
|
|
132
|
+
sliced = current[start:end]
|
|
133
|
+
|
|
134
|
+
if not remaining_segments:
|
|
135
|
+
return sliced
|
|
136
|
+
|
|
137
|
+
return [_get_by_segments(item, remaining_segments) for item in sliced]
|
|
138
|
+
|
|
139
|
+
elif isinstance(segment, int):
|
|
140
|
+
# Array index
|
|
141
|
+
if isinstance(current, list):
|
|
142
|
+
current = current[segment]
|
|
143
|
+
elif isinstance(current, dict):
|
|
144
|
+
# Some dicts use int keys (rare but possible)
|
|
145
|
+
current = current[segment]
|
|
146
|
+
else:
|
|
147
|
+
raise TypeError(f"Cannot index {type(current).__name__}")
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
# String key
|
|
151
|
+
if isinstance(current, dict):
|
|
152
|
+
if segment not in current:
|
|
153
|
+
raise KeyError(f"Key not found: {segment}")
|
|
154
|
+
current = current[segment]
|
|
155
|
+
elif isinstance(current, list):
|
|
156
|
+
# Maybe trying to get key from objects in list?
|
|
157
|
+
raise TypeError(f"Cannot get key '{segment}' from array. Use [*].{segment} for all items.")
|
|
158
|
+
else:
|
|
159
|
+
raise TypeError(f"Cannot get key from {type(current).__name__}")
|
|
160
|
+
|
|
161
|
+
return current
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def set_value(data: Any, path: str, value: Any) -> Any:
|
|
165
|
+
"""
|
|
166
|
+
Set value at path in JSON data (returns modified copy).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
data: JSON data
|
|
170
|
+
path: Dot-notation path
|
|
171
|
+
value: Value to set
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Modified copy of data
|
|
175
|
+
"""
|
|
176
|
+
if not path or path == '.':
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
segments = parse_path(path)
|
|
180
|
+
return _set_by_segments(data, segments, value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _set_by_segments(data: Any, segments: List, value: Any) -> Any:
|
|
184
|
+
"""Set value using parsed segments."""
|
|
185
|
+
if not segments:
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
segment = segments[0]
|
|
189
|
+
remaining = segments[1:]
|
|
190
|
+
|
|
191
|
+
if isinstance(segment, int):
|
|
192
|
+
# Array index
|
|
193
|
+
if data is None:
|
|
194
|
+
data = []
|
|
195
|
+
if not isinstance(data, list):
|
|
196
|
+
raise TypeError(f"Cannot index {type(data).__name__}")
|
|
197
|
+
|
|
198
|
+
# Extend list if needed
|
|
199
|
+
while len(data) <= segment:
|
|
200
|
+
data.append(None)
|
|
201
|
+
|
|
202
|
+
result = list(data)
|
|
203
|
+
result[segment] = _set_by_segments(result[segment], remaining, value)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
elif isinstance(segment, str):
|
|
207
|
+
# Object key
|
|
208
|
+
if data is None:
|
|
209
|
+
data = {}
|
|
210
|
+
if not isinstance(data, dict):
|
|
211
|
+
raise TypeError(f"Cannot set key on {type(data).__name__}")
|
|
212
|
+
|
|
213
|
+
result = dict(data)
|
|
214
|
+
result[segment] = _set_by_segments(result.get(segment), remaining, value)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
else:
|
|
218
|
+
raise ValueError(f"Cannot set value with wildcard or slice in path")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def delete_value(data: Any, path: str) -> Any:
|
|
222
|
+
"""
|
|
223
|
+
Delete value at path (returns modified copy).
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
data: JSON data
|
|
227
|
+
path: Dot-notation path
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Modified copy with value removed
|
|
231
|
+
"""
|
|
232
|
+
if not path or path == '.':
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
segments = parse_path(path)
|
|
236
|
+
return _delete_by_segments(data, segments)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _delete_by_segments(data: Any, segments: List) -> Any:
|
|
240
|
+
"""Delete value using parsed segments."""
|
|
241
|
+
if not segments:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
if len(segments) == 1:
|
|
245
|
+
segment = segments[0]
|
|
246
|
+
if isinstance(segment, int) and isinstance(data, list):
|
|
247
|
+
result = list(data)
|
|
248
|
+
del result[segment]
|
|
249
|
+
return result
|
|
250
|
+
elif isinstance(segment, str) and isinstance(data, dict):
|
|
251
|
+
result = dict(data)
|
|
252
|
+
del result[segment]
|
|
253
|
+
return result
|
|
254
|
+
else:
|
|
255
|
+
raise TypeError(f"Cannot delete from {type(data).__name__}")
|
|
256
|
+
|
|
257
|
+
segment = segments[0]
|
|
258
|
+
remaining = segments[1:]
|
|
259
|
+
|
|
260
|
+
if isinstance(segment, int) and isinstance(data, list):
|
|
261
|
+
result = list(data)
|
|
262
|
+
result[segment] = _delete_by_segments(result[segment], remaining)
|
|
263
|
+
return result
|
|
264
|
+
elif isinstance(segment, str) and isinstance(data, dict):
|
|
265
|
+
result = dict(data)
|
|
266
|
+
result[segment] = _delete_by_segments(result.get(segment), remaining)
|
|
267
|
+
return result
|
|
268
|
+
else:
|
|
269
|
+
raise TypeError(f"Cannot navigate {type(data).__name__}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def flatten(data: Any, prefix: str = "", sep: str = ".") -> dict:
|
|
273
|
+
"""
|
|
274
|
+
Flatten nested JSON to dot-notation keys.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
data: JSON data
|
|
278
|
+
prefix: Current path prefix
|
|
279
|
+
sep: Separator (default ".")
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Flat dict with dot-notation keys
|
|
283
|
+
"""
|
|
284
|
+
result = {}
|
|
285
|
+
|
|
286
|
+
if isinstance(data, dict):
|
|
287
|
+
for key, value in data.items():
|
|
288
|
+
new_key = f"{prefix}{sep}{key}" if prefix else key
|
|
289
|
+
if isinstance(value, (dict, list)):
|
|
290
|
+
result.update(flatten(value, new_key, sep))
|
|
291
|
+
else:
|
|
292
|
+
result[new_key] = value
|
|
293
|
+
|
|
294
|
+
elif isinstance(data, list):
|
|
295
|
+
for i, item in enumerate(data):
|
|
296
|
+
new_key = f"{prefix}[{i}]"
|
|
297
|
+
if isinstance(item, (dict, list)):
|
|
298
|
+
result.update(flatten(item, new_key, sep))
|
|
299
|
+
else:
|
|
300
|
+
result[new_key] = item
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
result[prefix] = data
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def unflatten(data: dict, sep: str = ".") -> Any:
|
|
309
|
+
"""
|
|
310
|
+
Unflatten dot-notation keys back to nested structure.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
data: Flat dict with dot-notation keys
|
|
314
|
+
sep: Separator (default ".")
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Nested JSON structure
|
|
318
|
+
"""
|
|
319
|
+
result = {}
|
|
320
|
+
|
|
321
|
+
for key, value in data.items():
|
|
322
|
+
parts = key.replace('[', sep + '[').split(sep)
|
|
323
|
+
current = result
|
|
324
|
+
|
|
325
|
+
for i, part in enumerate(parts[:-1]):
|
|
326
|
+
# Handle array indices
|
|
327
|
+
if part.startswith('[') and part.endswith(']'):
|
|
328
|
+
idx = int(part[1:-1])
|
|
329
|
+
while len(current) <= idx:
|
|
330
|
+
current.append(None)
|
|
331
|
+
if current[idx] is None:
|
|
332
|
+
# Peek ahead to determine type
|
|
333
|
+
next_part = parts[i + 1]
|
|
334
|
+
current[idx] = [] if next_part.startswith('[') else {}
|
|
335
|
+
current = current[idx]
|
|
336
|
+
else:
|
|
337
|
+
if part not in current:
|
|
338
|
+
# Peek ahead to determine type
|
|
339
|
+
next_part = parts[i + 1]
|
|
340
|
+
current[part] = [] if next_part.startswith('[') else {}
|
|
341
|
+
current = current[part]
|
|
342
|
+
|
|
343
|
+
# Set final value
|
|
344
|
+
last = parts[-1]
|
|
345
|
+
if last.startswith('[') and last.endswith(']'):
|
|
346
|
+
idx = int(last[1:-1])
|
|
347
|
+
while len(current) <= idx:
|
|
348
|
+
current.append(None)
|
|
349
|
+
current[idx] = value
|
|
350
|
+
else:
|
|
351
|
+
current[last] = value
|
|
352
|
+
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def keys(data: Any, recursive: bool = False) -> List[str]:
|
|
357
|
+
"""
|
|
358
|
+
Get all keys from JSON data.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
data: JSON data
|
|
362
|
+
recursive: If True, return all nested keys with paths
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
List of keys (or paths if recursive)
|
|
366
|
+
"""
|
|
367
|
+
if not isinstance(data, dict):
|
|
368
|
+
return []
|
|
369
|
+
|
|
370
|
+
if not recursive:
|
|
371
|
+
return list(data.keys())
|
|
372
|
+
|
|
373
|
+
return list(flatten(data).keys())
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def merge(base: Any, overlay: Any, deep: bool = True) -> Any:
|
|
377
|
+
"""
|
|
378
|
+
Merge two JSON structures.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
base: Base data
|
|
382
|
+
overlay: Data to merge on top
|
|
383
|
+
deep: If True, merge nested dicts recursively
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Merged data
|
|
387
|
+
"""
|
|
388
|
+
if not isinstance(base, dict) or not isinstance(overlay, dict):
|
|
389
|
+
return overlay
|
|
390
|
+
|
|
391
|
+
result = dict(base)
|
|
392
|
+
|
|
393
|
+
for key, value in overlay.items():
|
|
394
|
+
if deep and key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
395
|
+
result[key] = merge(result[key], value, deep)
|
|
396
|
+
else:
|
|
397
|
+
result[key] = value
|
|
398
|
+
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def diff(a: Any, b: Any, path: str = "") -> List[dict]:
|
|
403
|
+
"""
|
|
404
|
+
Find differences between two JSON structures.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
a: First JSON data
|
|
408
|
+
b: Second JSON data
|
|
409
|
+
path: Current path (internal)
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List of difference objects with path, type, and values
|
|
413
|
+
"""
|
|
414
|
+
differences = []
|
|
415
|
+
|
|
416
|
+
if type(a) != type(b):
|
|
417
|
+
return [{"path": path or ".", "type": "type_change", "old": a, "new": b}]
|
|
418
|
+
|
|
419
|
+
if isinstance(a, dict):
|
|
420
|
+
all_keys = set(a.keys()) | set(b.keys())
|
|
421
|
+
for key in all_keys:
|
|
422
|
+
new_path = f"{path}.{key}" if path else key
|
|
423
|
+
if key not in a:
|
|
424
|
+
differences.append({"path": new_path, "type": "added", "value": b[key]})
|
|
425
|
+
elif key not in b:
|
|
426
|
+
differences.append({"path": new_path, "type": "removed", "value": a[key]})
|
|
427
|
+
else:
|
|
428
|
+
differences.extend(diff(a[key], b[key], new_path))
|
|
429
|
+
|
|
430
|
+
elif isinstance(a, list):
|
|
431
|
+
max_len = max(len(a), len(b))
|
|
432
|
+
for i in range(max_len):
|
|
433
|
+
new_path = f"{path}[{i}]"
|
|
434
|
+
if i >= len(a):
|
|
435
|
+
differences.append({"path": new_path, "type": "added", "value": b[i]})
|
|
436
|
+
elif i >= len(b):
|
|
437
|
+
differences.append({"path": new_path, "type": "removed", "value": a[i]})
|
|
438
|
+
else:
|
|
439
|
+
differences.extend(diff(a[i], b[i], new_path))
|
|
440
|
+
|
|
441
|
+
else:
|
|
442
|
+
if a != b:
|
|
443
|
+
differences.append({"path": path or ".", "type": "changed", "old": a, "new": b})
|
|
444
|
+
|
|
445
|
+
return differences
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jsonx-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JSON query and transform CLI - simpler than jq
|
|
5
|
+
Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/jsonx
|
|
6
|
+
Project-URL: Repository, https://github.com/marcusbuildsthings-droid/jsonx
|
|
7
|
+
Project-URL: Issues, https://github.com/marcusbuildsthings-droid/jsonx/issues
|
|
8
|
+
Author-email: Marcus <marcus.builds.things@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,cli,jq,json,query,transform
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Requires-Dist: click>=8.0.0
|
|
27
|
+
Provides-Extra: schema
|
|
28
|
+
Requires-Dist: jsonschema>=4.0.0; extra == 'schema'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# jsonx
|
|
32
|
+
|
|
33
|
+
JSON query and transform CLI — simpler than jq.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/jsonx-cli/)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install jsonx-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For JSON Schema validation:
|
|
44
|
+
```bash
|
|
45
|
+
pip install jsonx-cli[schema]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Get a nested value
|
|
52
|
+
jsonx get .users[0].name data.json
|
|
53
|
+
|
|
54
|
+
# From stdin
|
|
55
|
+
cat data.json | jsonx get .config.debug
|
|
56
|
+
|
|
57
|
+
# Set a value
|
|
58
|
+
jsonx set .config.debug true data.json
|
|
59
|
+
|
|
60
|
+
# List all keys
|
|
61
|
+
jsonx keys data.json
|
|
62
|
+
|
|
63
|
+
# Pretty print
|
|
64
|
+
jsonx format data.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Path Syntax
|
|
68
|
+
|
|
69
|
+
jsonx uses simple dot notation:
|
|
70
|
+
|
|
71
|
+
| Pattern | Meaning |
|
|
72
|
+
|---------|---------|
|
|
73
|
+
| `.key` | Object property |
|
|
74
|
+
| `.nested.key` | Nested property |
|
|
75
|
+
| `[0]` | Array index |
|
|
76
|
+
| `[-1]` | Last array item |
|
|
77
|
+
| `[*]` | All array items |
|
|
78
|
+
| `[0:3]` | Array slice |
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
```bash
|
|
82
|
+
jsonx get .name data.json # Get "name" property
|
|
83
|
+
jsonx get .users[0] data.json # First user
|
|
84
|
+
jsonx get .users[-1] data.json # Last user
|
|
85
|
+
jsonx get .users[*].email data.json # All user emails
|
|
86
|
+
jsonx get .items[0:5] data.json # First 5 items
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
### get - Extract values
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
jsonx get .path [file] # Extract value at path
|
|
95
|
+
jsonx get .path file --raw # Raw output (no quotes)
|
|
96
|
+
jsonx get .path file --json # Force JSON output
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### set - Modify values
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
jsonx set .path value [file] # Set and output
|
|
103
|
+
jsonx set .path value file -i # Modify in place
|
|
104
|
+
jsonx set .path value file -o out.json # Write to file
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Value is parsed as JSON:
|
|
108
|
+
```bash
|
|
109
|
+
jsonx set .debug true data.json # boolean
|
|
110
|
+
jsonx set .count 42 data.json # number
|
|
111
|
+
jsonx set .name '"Alice"' data.json # string (note quotes)
|
|
112
|
+
jsonx set .tags '["a","b"]' data.json # array
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### del - Delete values
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
jsonx del .path [file] # Delete and output
|
|
119
|
+
jsonx del .path file -i # Delete in place
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### keys - List keys
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
jsonx keys [file] # Top-level keys
|
|
126
|
+
jsonx keys file -r # All keys recursively
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### flatten / unflatten - Structure transformation
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Flatten nested structure
|
|
133
|
+
jsonx flatten data.json
|
|
134
|
+
# {"users[0].name": "Alice", "config.debug": true}
|
|
135
|
+
|
|
136
|
+
# Unflatten back
|
|
137
|
+
echo '{"user.name": "Alice"}' | jsonx unflatten
|
|
138
|
+
# {"user": {"name": "Alice"}}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### merge - Combine files
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
jsonx merge base.json override.json # Deep merge
|
|
145
|
+
jsonx merge a.json b.json --shallow # Shallow merge
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### diff - Compare files
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
jsonx diff old.json new.json
|
|
152
|
+
# + config.newKey: "value" (green - added)
|
|
153
|
+
# - config.removed: true (red - removed)
|
|
154
|
+
# ~ config.changed: 1 → 2 (yellow - changed)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### format / minify - Pretty print
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
jsonx format data.json # Pretty print
|
|
161
|
+
jsonx format data.json -i 4 # 4-space indent
|
|
162
|
+
jsonx minify data.json # Compact
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### validate - JSON Schema
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
jsonx validate data.json schema.json
|
|
169
|
+
# ✓ Valid
|
|
170
|
+
# or
|
|
171
|
+
# ✗ Invalid: 'email' is required
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### type - Show value type
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
echo '[]' | jsonx type # array
|
|
178
|
+
echo '{}' | jsonx type # object
|
|
179
|
+
echo '42' | jsonx type # integer
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### count - Count items
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
jsonx count data.json # Top-level count
|
|
186
|
+
jsonx get .users data.json | jsonx count # Count array items
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### each - Iterate arrays
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# Output each item as JSON
|
|
193
|
+
jsonx each data.json -p .users
|
|
194
|
+
|
|
195
|
+
# With template
|
|
196
|
+
jsonx each data.json -p .users -t '{.name}: {.email}'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Piping
|
|
200
|
+
|
|
201
|
+
jsonx works great in pipelines:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Extract, transform, merge
|
|
205
|
+
cat config.json | jsonx get .database | jsonx set .host '"prod.db"'
|
|
206
|
+
|
|
207
|
+
# Count users
|
|
208
|
+
jsonx get .users data.json | jsonx count
|
|
209
|
+
|
|
210
|
+
# Extract all emails
|
|
211
|
+
jsonx get .users[*].email data.json
|
|
212
|
+
|
|
213
|
+
# Filter and format
|
|
214
|
+
curl api/data | jsonx get .results | jsonx format
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## For AI Agents
|
|
218
|
+
|
|
219
|
+
See [SKILL.md](SKILL.md) for agent-optimized documentation.
|
|
220
|
+
|
|
221
|
+
## Exit Codes
|
|
222
|
+
|
|
223
|
+
- `0` - Success
|
|
224
|
+
- `1` - Error (invalid JSON, path not found, etc.)
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
jsonx/__init__.py,sha256=spRgGeMbGVHl4Pq-gtFd-OIC1PKaS7XPm46feruLj8I,66
|
|
2
|
+
jsonx/cli.py,sha256=PsUrtJYyC0yO2mIc0qXwNyd4Rj_NeWwNSGu48fN3wBU,15310
|
|
3
|
+
jsonx/path.py,sha256=YKRGfDUXkTBjqPDO90lo-U9_kY98ZRwcp9mwoIjFbzo,13290
|
|
4
|
+
jsonx_cli-0.1.0.dist-info/METADATA,sha256=jK0-SruqZ0g4PwQwyl70ugu6Wf50OmSsEEV5k_JpSho,5283
|
|
5
|
+
jsonx_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
jsonx_cli-0.1.0.dist-info/entry_points.txt,sha256=joRB7Mfd6w4Xz2T4ZlJCf0e0hRcZ9TtoY_SNih0fzCg,41
|
|
7
|
+
jsonx_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
|
|
8
|
+
jsonx_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcus
|
|
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.
|