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/__init__.py +32 -0
- jsonpit/canonical.py +105 -0
- jsonpit/changes.py +186 -0
- jsonpit/cli.py +528 -0
- jsonpit/config.py +341 -0
- jsonpit/exceptions.py +27 -0
- jsonpit/flags.py +274 -0
- jsonpit/fs.py +142 -0
- jsonpit/history.py +203 -0
- jsonpit/item.py +426 -0
- jsonpit/store.py +645 -0
- jsonpit-0.1.0.dist-info/METADATA +153 -0
- jsonpit-0.1.0.dist-info/RECORD +15 -0
- jsonpit-0.1.0.dist-info/WHEEL +4 -0
- jsonpit-0.1.0.dist-info/entry_points.txt +3 -0
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())
|