jsonpit 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.
jsonpit/cli.py ADDED
@@ -0,0 +1,528 @@
1
+ """
2
+ Command-line interface for jsonpit and jpit.
3
+ Provides living-state semantic search (Pit-Grep), stream piping with jq,
4
+ point-in-time time-travel, and JSON5 mutation ingestion.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import copy
11
+ import datetime
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from typing import Any
20
+
21
+ from .canonical import canonical_json, format_iso_timestamp, parse_iso_timestamp, utcnow
22
+ from .config import OsConfig, loads_json5
23
+ from .exceptions import JsonPitError, PitNotFoundError
24
+ from .fs import resolve_pit_target
25
+ from .item import PitItem
26
+ from .store import Pit
27
+
28
+
29
+ def run_jq_filter(json_text: str, jq_filter: str) -> None:
30
+ """Pipes json_text through the system jq executable."""
31
+ jq_bin = shutil.which("jq") or "/usr/bin/jq"
32
+ if not os.path.isfile(jq_bin):
33
+ sys.stderr.write("[jpit] Error: 'jq' executable not found on system PATH.\n")
34
+ sys.stdout.write(json_text)
35
+ return
36
+
37
+ proc = subprocess.Popen(
38
+ [jq_bin, jq_filter],
39
+ stdin=subprocess.PIPE,
40
+ stdout=sys.stdout,
41
+ stderr=sys.stderr,
42
+ text=True,
43
+ )
44
+ proc.communicate(input=json_text)
45
+
46
+
47
+ def discover_pits(
48
+ target: str,
49
+ cloud: str | None = "OneDrive",
50
+ root: str | None = None,
51
+ ) -> list[tuple[Path, str]]:
52
+ """
53
+ Discovers one or multiple pits based on a target name or root.
54
+ If target is an entire root (e.g. 'AIA'), discovers all contained pits.
55
+ """
56
+ # Check if target is a single pit or an explicit directory
57
+ try:
58
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
59
+ if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
60
+ return [(p_dir, p_name)]
61
+ except Exception:
62
+ pass
63
+
64
+ # Check if target is a tenant/root folder containing multiple pit directories
65
+ cfg = OsConfig.load()
66
+ cloud_root = cfg.get_cloud_root(cloud)
67
+ candidates: list[Path] = []
68
+ if cloud_root and (cloud_root / target).is_dir():
69
+ candidates.append(cloud_root / target)
70
+ if Path(target).is_dir():
71
+ candidates.append(Path(target))
72
+
73
+ discovered: list[tuple[Path, str]] = []
74
+ for candidate_root in candidates:
75
+ for child in sorted(candidate_root.iterdir()):
76
+ if child.is_dir():
77
+ pit_file = child / f"{child.name}.pit"
78
+ if pit_file.is_file():
79
+ discovered.append((child, child.name))
80
+
81
+ if discovered:
82
+ return discovered
83
+
84
+ # Fallback single pit target
85
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
86
+ return [(p_dir, p_name)]
87
+
88
+
89
+ def cmd_grep(args: argparse.Namespace) -> int:
90
+ """
91
+ Pit-Grep: Living-state semantic search across single or multiple pits.
92
+ Filters out deleted entities by default, supports time-travel and property scoping.
93
+ """
94
+ at_time: datetime.datetime | None = None
95
+ if args.at:
96
+ at_time = parse_iso_timestamp(args.at)
97
+
98
+ flags = re.IGNORECASE if args.ignore_case else 0
99
+ try:
100
+ pattern = re.compile(args.pattern if args.regex else re.escape(args.pattern), flags)
101
+ except re.error as ex:
102
+ sys.stderr.write(f"[jpit] Regex error: {ex}\n")
103
+ return 1
104
+
105
+ pits = discover_pits(args.target, cloud=args.cloud, root=args.root)
106
+ if not pits:
107
+ sys.stderr.write(f"[jpit] No pits found for target '{args.target}'.\n")
108
+ return 1
109
+
110
+ matched_entities: list[dict[str, Any]] = []
111
+
112
+ for pit_dir, pit_name in pits:
113
+ try:
114
+ with Pit(
115
+ pit_dir=pit_dir,
116
+ pit_name=pit_name,
117
+ read_only=True,
118
+ unflagged=True,
119
+ ) as pit:
120
+ for key in pit.keys():
121
+ item = pit.get(key, at=at_time, with_deleted=args.with_deleted)
122
+ if item is None:
123
+ continue
124
+ item_dict = item.to_dict()
125
+
126
+ # Property-scoped search or broad search
127
+ matches_found: list[tuple[str, str]] = []
128
+ if args.property:
129
+ val = _get_nested(item_dict, args.property)
130
+ val_str = str(val) if val is not None else ""
131
+ if pattern.search(val_str):
132
+ matches_found.append((args.property, val_str))
133
+ else:
134
+ # Search all attributes
135
+ for k, v in item_dict.items():
136
+ v_str = str(v)
137
+ if pattern.search(v_str):
138
+ matches_found.append((k, v_str))
139
+
140
+ if matches_found:
141
+ record = copy.deepcopy(item_dict)
142
+ record["_pit"] = pit_name
143
+ matched_entities.append(record)
144
+
145
+ if not args.json:
146
+ label = record.get("Name") or record.get("Id")
147
+ print(f"\033[1;34m[{pit_name} : {record.get('Id')}]\033[0m \033[1m({label})\033[0m")
148
+ for prop_k, prop_v in matches_found[:4]:
149
+ highlighted = pattern.sub(
150
+ lambda m: f"\033[1;31m{m.group(0)}\033[0m",
151
+ prop_v.replace("\n", " ")[:120],
152
+ )
153
+ print(f" \033[36m.{prop_k}\033[0m: {highlighted}")
154
+ print()
155
+ except Exception as ex:
156
+ sys.stderr.write(f"[jpit] Error reading pit {pit_name}: {ex}\n")
157
+
158
+ if args.json:
159
+ raw_json = json.dumps(matched_entities, indent=2, ensure_ascii=False)
160
+ if args.jq:
161
+ run_jq_filter(raw_json, args.jq)
162
+ else:
163
+ sys.stdout.write(raw_json + "\n")
164
+
165
+ return 0
166
+
167
+
168
+ def _get_nested(data: dict[str, Any], path: str) -> Any:
169
+ """Navigates dot-delimited path in a dictionary."""
170
+ curr: Any = data
171
+ for part in path.split("."):
172
+ if isinstance(curr, dict):
173
+ curr = curr.get(part)
174
+ else:
175
+ return None
176
+ return curr
177
+
178
+
179
+ def cmd_get(args: argparse.Namespace) -> int:
180
+ """Retrieves an entity projection, optionally filtered by timestamp or jq."""
181
+ at_time = parse_iso_timestamp(args.at) if args.at else None
182
+ with Pit.open(
183
+ args.pit,
184
+ cloud=args.cloud,
185
+ root=args.root,
186
+ read_only=True,
187
+ unflagged=True,
188
+ ) as pit:
189
+ item = pit.get(args.id, at=at_time, with_deleted=args.with_deleted)
190
+ if item is None:
191
+ sys.stderr.write(f"[jpit] Entity '{args.id}' not found in Pit '{pit.pit_name}'.\n")
192
+ return 1
193
+
194
+ output = json.dumps(item.to_dict(), indent=2, ensure_ascii=False)
195
+ if args.jq:
196
+ run_jq_filter(output, args.jq)
197
+ else:
198
+ sys.stdout.write(output + "\n")
199
+ return 0
200
+
201
+
202
+ def cmd_history(args: argparse.Namespace) -> int:
203
+ """Dumps the raw immutable fragment history array for an entity."""
204
+ with Pit.open(
205
+ args.pit,
206
+ cloud=args.cloud,
207
+ root=args.root,
208
+ read_only=True,
209
+ unflagged=True,
210
+ ) as pit:
211
+ pit_items = pit._historic_items.get(args.id)
212
+ if pit_items is None:
213
+ sys.stderr.write(f"[jpit] No history for '{args.id}' in Pit '{pit.pit_name}'.\n")
214
+ return 1
215
+
216
+ fragments = [frag.to_dict() for frag in pit_items.history]
217
+ output = json.dumps(fragments, indent=2, ensure_ascii=False)
218
+ if args.jq:
219
+ run_jq_filter(output, args.jq)
220
+ else:
221
+ sys.stdout.write(output + "\n")
222
+ return 0
223
+
224
+
225
+ def cmd_list(args: argparse.Namespace) -> int:
226
+ """Lists active entities in a Pit."""
227
+ with Pit.open(
228
+ args.pit,
229
+ cloud=args.cloud,
230
+ root=args.root,
231
+ read_only=True,
232
+ unflagged=True,
233
+ ) as pit:
234
+ keys = list(pit.keys())
235
+ if args.json:
236
+ entities = [pit[k].to_dict() for k in keys]
237
+ output = json.dumps(entities, indent=2, ensure_ascii=False)
238
+ if args.jq:
239
+ run_jq_filter(output, args.jq)
240
+ else:
241
+ sys.stdout.write(output + "\n")
242
+ return 0
243
+
244
+ print(f"\033[1mPit: {pit.pit_name}\033[0m ({len(keys)} active entities) at {pit.pit_dir}\n")
245
+ for k in keys:
246
+ item = pit[k]
247
+ name = item.get("Name") or item.get("Alias") or k
248
+ cls_label = f"[{item.get('Class')}]" if item.get("Class") else ""
249
+ print(f" • \033[1;34m{k:<24}\033[0m {name:<30} {cls_label}")
250
+ return 0
251
+
252
+
253
+ def cmd_put(args: argparse.Namespace) -> int:
254
+ """
255
+ Ingests entities from stdin, piped jq output, or a JSON5 file.
256
+ Adds entities cloud-safely with leasing.
257
+ """
258
+ # Read source content
259
+ content = ""
260
+ if args.source and args.source != "-":
261
+ path = Path(args.source)
262
+ if not path.is_file():
263
+ sys.stderr.write(f"[jpit] Source file not found: {path}\n")
264
+ return 1
265
+ content = path.read_text(encoding="utf-8")
266
+ else:
267
+ content = sys.stdin.read()
268
+
269
+ if not content.strip():
270
+ sys.stderr.write("[jpit] Error: No input data provided.\n")
271
+ return 1
272
+
273
+ data = loads_json5(content)
274
+ items_to_add: list[dict[str, Any]] = []
275
+ if isinstance(data, list):
276
+ items_to_add = [row for row in data if isinstance(row, dict)]
277
+ elif isinstance(data, dict):
278
+ if "Id" in data or "id" in data:
279
+ items_to_add = [data]
280
+ else:
281
+ for k, v in data.items():
282
+ if isinstance(v, dict):
283
+ v["Id"] = k
284
+ items_to_add.append(v)
285
+
286
+ if not items_to_add:
287
+ sys.stderr.write("[jpit] Error: Input data contains no valid entity objects.\n")
288
+ return 1
289
+
290
+ with Pit.open(args.pit, cloud=args.cloud, root=args.root) as pit:
291
+ count = 0
292
+ for raw_obj in items_to_add:
293
+ item_id = raw_obj.get("Id") or raw_obj.get("id") or raw_obj.get("Name")
294
+ if not item_id:
295
+ continue
296
+ item = PitItem(raw_obj, id=str(item_id))
297
+ pit.add(item)
298
+ count += 1
299
+
300
+ print(f"[jpit] Successfully committed {count} entity(ies) to Pit '{args.pit}'.")
301
+ return 0
302
+
303
+
304
+ def cmd_set(args: argparse.Namespace) -> int:
305
+ """Mutates or creates an entity using a JSON5 string payload."""
306
+ payload = loads_json5(args.payload)
307
+ if not isinstance(payload, dict):
308
+ sys.stderr.write("[jpit] Payload must evaluate to a JSON5 object.\n")
309
+ return 1
310
+
311
+ with Pit.open(args.pit, cloud=args.cloud, root=args.root) as pit:
312
+ existing = pit.get(args.id)
313
+ if existing:
314
+ existing.set_property(payload)
315
+ pit.add(existing)
316
+ else:
317
+ payload["Id"] = args.id
318
+ new_item = PitItem(payload)
319
+ pit.add(new_item)
320
+
321
+ print(f"[jpit] Updated entity '{args.id}' in Pit '{args.pit}'.")
322
+ return 0
323
+
324
+
325
+ def cmd_delete(args: argparse.Namespace) -> int:
326
+ """Tombstones an entity with audited author note and 100s backdating."""
327
+ with Pit.open(args.pit, cloud=args.cloud, root=args.root) as pit:
328
+ success = pit.delete_item(args.id, by=args.by)
329
+ if not success:
330
+ sys.stderr.write(f"[jpit] Entity '{args.id}' is already deleted or not found.\n")
331
+ return 1
332
+
333
+ print(f"[jpit] Tombstoned entity '{args.id}' in Pit '{args.pit}'.")
334
+ return 0
335
+
336
+
337
+ def cmd_delete_prop(args: argparse.Namespace) -> int:
338
+ """Appends a property tombstone at a dot-delimited property path."""
339
+ with Pit.open(args.pit, cloud=args.cloud, root=args.root) as pit:
340
+ item = pit.get(args.id)
341
+ if item is None:
342
+ sys.stderr.write(f"[jpit] Entity '{args.id}' not found.\n")
343
+ return 1
344
+ item.delete_property_path(args.property_path)
345
+ pit.add(item)
346
+
347
+ print(f"[jpit] Tombstoned property '{args.property_path}' on entity '{args.id}'.")
348
+ return 0
349
+
350
+
351
+ def cmd_export(args: argparse.Namespace) -> int:
352
+ """Exports all projected entities as JSON to stdout or file."""
353
+ at_time = parse_iso_timestamp(args.at) if args.at else None
354
+ with Pit.open(
355
+ args.pit,
356
+ cloud=args.cloud,
357
+ root=args.root,
358
+ read_only=True,
359
+ unflagged=True,
360
+ ) as pit:
361
+ entities = [
362
+ pit.get(k, at=at_time).to_dict()
363
+ for k in pit.keys()
364
+ if pit.get(k, at=at_time) is not None
365
+ ]
366
+ raw_json = json.dumps(entities, indent=2, ensure_ascii=False)
367
+
368
+ if args.out:
369
+ Path(args.out).write_text(raw_json, encoding="utf-8")
370
+ print(f"[jpit] Exported {len(entities)} entities to {args.out}")
371
+ elif args.jq:
372
+ run_jq_filter(raw_json, args.jq)
373
+ else:
374
+ sys.stdout.write(raw_json + "\n")
375
+ return 0
376
+
377
+
378
+ def cmd_status(args: argparse.Namespace) -> int:
379
+ """Inspects master lease status, process windows, and pending change files."""
380
+ with Pit.open(
381
+ args.pit,
382
+ cloud=args.cloud,
383
+ root=args.root,
384
+ read_only=True,
385
+ unflagged=True,
386
+ ) as pit:
387
+ print(f"\033[1m=== Pit Status: {pit.pit_name} ===\033[0m")
388
+ print(f"Path: {pit.pit_dir}")
389
+ print(f"Canonical file: {pit.canonical_file.name} (exists={pit.canonical_file.is_file()})")
390
+ print(f"Active entities: {len(pit)}")
391
+
392
+ # Master flag inspection
393
+ master = pit.master_flag
394
+ print(f"\n\033[1mMaster Lease:\033[0m")
395
+ print(f" Originator: {master.originator or 'None'}")
396
+ print(f" Time: {format_iso_timestamp(master.time)}")
397
+ print(f" Is Expired: {master.is_expired}")
398
+
399
+ # Process flags
400
+ proc_flags = list(pit.pit_dir.glob("*.flag"))
401
+ print(f"\n\033[1mFlag Files ({len(proc_flags)}):\033[0m")
402
+ for pf in proc_flags:
403
+ print(f" • {pf.name}")
404
+
405
+ # Change files
406
+ change_files = [p for p in pit.pit_dir.glob("*.json") if p.name != pit.canonical_file.name]
407
+ print(f"\n\033[1mPending Change Files ({len(change_files)}):\033[0m")
408
+ for cf in change_files[:5]:
409
+ print(f" • {cf.name}")
410
+ if len(change_files) > 5:
411
+ print(f" ... and {len(change_files) - 5} more.")
412
+
413
+ return 0
414
+
415
+
416
+ def build_parser() -> argparse.ArgumentParser:
417
+ parser = argparse.ArgumentParser(
418
+ prog="jpit",
419
+ description="jpit — Cloud-first distributed replicated storage CLI and Pit-Grep.",
420
+ )
421
+ parser.add_argument("-c", "--cloud", default="OneDrive", help="Cloud drive (OneDrive, Dropbox, etc.)")
422
+ parser.add_argument("-r", "--root", default=None, help="Root folder / tenant (e.g. AIA, AfricaStage)")
423
+
424
+ subparsers = parser.add_subparsers(dest="command", required=True)
425
+
426
+ # grep (Pit-Grep)
427
+ p_grep = subparsers.add_parser("grep", help="Ripgrep-style living state search across pits")
428
+ p_grep.add_argument("target", help="Pit name or root directory (e.g. Person, AIA)")
429
+ p_grep.add_argument("pattern", help="Text or regex to match")
430
+ p_grep.add_argument("-i", "--ignore-case", action="store_true", help="Case-insensitive search")
431
+ p_grep.add_argument("-e", "--regex", action="store_true", help="Treat pattern as regex")
432
+ p_grep.add_argument("-p", "--property", help="Scope search to a specific property path")
433
+ p_grep.add_argument("--at", help="Project state as of timestamp")
434
+ p_grep.add_argument("--json", action="store_true", help="Output JSON stream for piping to jq")
435
+ p_grep.add_argument("--jq", help="Convenience pipe through jq filter")
436
+ p_grep.add_argument("--with-deleted", action="store_true", help="Include tombstoned entities")
437
+
438
+ # get
439
+ p_get = subparsers.add_parser("get", help="Get entity projected state")
440
+ p_get.add_argument("pit", help="Pit name")
441
+ p_get.add_argument("id", help="Entity ID")
442
+ p_get.add_argument("--at", help="Point-in-time timestamp")
443
+ p_get.add_argument("--jq", help="Convenience pipe through jq filter")
444
+ p_get.add_argument("--with-deleted", action="store_true", help="Include tombstoned entities")
445
+
446
+ # history
447
+ p_hist = subparsers.add_parser("history", help="Dump immutable fragment history for an entity")
448
+ p_hist.add_argument("pit", help="Pit name")
449
+ p_hist.add_argument("id", help="Entity ID")
450
+ p_hist.add_argument("--jq", help="Convenience pipe through jq filter")
451
+
452
+ # list
453
+ p_list = subparsers.add_parser("list", help="List active entities in a Pit")
454
+ p_list.add_argument("pit", help="Pit name")
455
+ p_list.add_argument("--json", action="store_true", help="Output JSON array")
456
+ p_list.add_argument("--jq", help="Convenience pipe through jq filter")
457
+
458
+ # put
459
+ p_put = subparsers.add_parser("put", help="Ingest JSON5 entities from file or stdin pipe")
460
+ p_put.add_argument("pit", help="Pit name")
461
+ p_put.add_argument("source", nargs="?", default="-", help="Source file or '-' for stdin")
462
+
463
+ # set
464
+ p_set = subparsers.add_parser("set", help="Set or patch entity with JSON5 payload")
465
+ p_set.add_argument("pit", help="Pit name")
466
+ p_set.add_argument("id", help="Entity ID")
467
+ p_set.add_argument("payload", help="JSON5 dictionary payload")
468
+
469
+ # del
470
+ p_del = subparsers.add_parser("del", help="Tombstone an entity")
471
+ p_del.add_argument("pit", help="Pit name")
472
+ p_del.add_argument("id", help="Entity ID")
473
+ p_del.add_argument("--by", help="Audited author identity")
474
+
475
+ # del-prop
476
+ p_delprop = subparsers.add_parser("del-prop", help="Tombstone a property path")
477
+ p_delprop.add_argument("pit", help="Pit name")
478
+ p_delprop.add_argument("id", help="Entity ID")
479
+ p_delprop.add_argument("property_path", help="Dot-delimited property path")
480
+
481
+ # export
482
+ p_export = subparsers.add_parser("export", help="Export entities as JSON array")
483
+ p_export.add_argument("pit", help="Pit name")
484
+ p_export.add_argument("--out", help="Output file path (default stdout)")
485
+ p_export.add_argument("--at", help="Project state as of timestamp")
486
+ p_export.add_argument("--jq", help="Convenience pipe through jq filter")
487
+
488
+ # status
489
+ p_status = subparsers.add_parser("status", help="Inspect Pit directory lease and flags")
490
+ p_status.add_argument("pit", help="Pit name")
491
+
492
+ return parser
493
+
494
+
495
+ def main(argv: list[str] | None = None) -> int:
496
+ parser = build_parser()
497
+ args = parser.parse_args(argv)
498
+
499
+ dispatch = {
500
+ "grep": cmd_grep,
501
+ "get": cmd_get,
502
+ "history": cmd_history,
503
+ "list": cmd_list,
504
+ "put": cmd_put,
505
+ "set": cmd_set,
506
+ "del": cmd_delete,
507
+ "del-prop": cmd_delete_prop,
508
+ "export": cmd_export,
509
+ "status": cmd_status,
510
+ }
511
+
512
+ cmd_func = dispatch.get(args.command)
513
+ if not cmd_func:
514
+ parser.print_help()
515
+ return 1
516
+
517
+ try:
518
+ return cmd_func(args)
519
+ except JsonPitError as ex:
520
+ sys.stderr.write(f"[jpit] Pit error: {ex}\n")
521
+ return 1
522
+ except Exception as ex:
523
+ sys.stderr.write(f"[jpit] Unexpected error: {ex}\n")
524
+ return 2
525
+
526
+
527
+ if __name__ == "__main__":
528
+ sys.exit(main())