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 ADDED
@@ -0,0 +1,3 @@
1
+ """jsonx - JSON query and transform CLI"""
2
+
3
+ __version__ = "0.1.0"
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
+ [![PyPI version](https://badge.fury.io/py/jsonx-cli.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jsonx = jsonx.cli:main
@@ -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.