jsonpit 4.4.2__tar.gz → 4.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jsonpit-4.4.2 → jsonpit-4.4.3}/PKG-INFO +3 -3
- {jsonpit-4.4.2 → jsonpit-4.4.3}/README.md +2 -2
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/__init__.py +4 -2
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/cli.py +40 -42
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/item.py +15 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/store.py +76 -21
- {jsonpit-4.4.2 → jsonpit-4.4.3}/pyproject.toml +1 -1
- {jsonpit-4.4.2 → jsonpit-4.4.3}/.gitignore +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/canonical.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/changes.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/config.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/exceptions.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/flags.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/fs.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/history.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.3}/jsonpit/icons.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jsonpit
|
|
3
|
-
Version: 4.4.
|
|
3
|
+
Version: 4.4.3
|
|
4
4
|
Summary: Daemon-free distributed storage over Cloud Drives for developers and AI agents — multi-process, immutable history, zero dependencies.
|
|
5
5
|
Author: Dr. Rainer Burkhardt
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -31,9 +31,9 @@ The canonical **reference (lead) implementation** is C# `JsonPit` and the `pits`
|
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
-
## Cross-Tool & Cross-Language Version Alignment (`v4.4.
|
|
34
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.3`)
|
|
35
35
|
|
|
36
|
-
`jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.
|
|
36
|
+
`jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.3`** in direct lockstep with C# `JsonPit` and `pits 4.4.3` in [RAIkeep](https://github.com/Burkhardt/RAIkeep).
|
|
37
37
|
|
|
38
38
|
### Why Harmonized Versioning is Essential:
|
|
39
39
|
- **Instant Compatibility Clarity:** Operators, DevOps pipelines, and autonomous AI agents immediately know whether `jpit` and `pits` share the exact same distributed protocol revision. If both tools report `4.4.x`, they guarantee identical master leasing rules, receipt mechanisms, 7-digit UTC timestamp parsing, and CR041 clean change file conventions.
|
|
@@ -9,9 +9,9 @@ The canonical **reference (lead) implementation** is C# `JsonPit` and the `pits`
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
## Cross-Tool & Cross-Language Version Alignment (`v4.4.
|
|
12
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.3`)
|
|
13
13
|
|
|
14
|
-
`jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.
|
|
14
|
+
`jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.3`** in direct lockstep with C# `JsonPit` and `pits 4.4.3` in [RAIkeep](https://github.com/Burkhardt/RAIkeep).
|
|
15
15
|
|
|
16
16
|
### Why Harmonized Versioning is Essential:
|
|
17
17
|
- **Instant Compatibility Clarity:** Operators, DevOps pipelines, and autonomous AI agents immediately know whether `jpit` and `pits` share the exact same distributed protocol revision. If both tools report `4.4.x`, they guarantee identical master leasing rules, receipt mechanisms, 7-digit UTC timestamp parsing, and CR041 clean change file conventions.
|
|
@@ -16,16 +16,18 @@ from .fs import OsConfig
|
|
|
16
16
|
from .history import PitItems
|
|
17
17
|
from .icons import Icons
|
|
18
18
|
from .item import PitItem
|
|
19
|
-
from .store import Pit
|
|
19
|
+
from .store import Pit, PitStore, parse_and_validate_seed_payload
|
|
20
20
|
|
|
21
21
|
try:
|
|
22
22
|
import importlib.metadata
|
|
23
23
|
__version__ = importlib.metadata.version("jsonpit")
|
|
24
24
|
except Exception:
|
|
25
|
-
__version__ = "4.4.
|
|
25
|
+
__version__ = "4.4.3"
|
|
26
26
|
|
|
27
27
|
__all__ = [
|
|
28
28
|
"Pit",
|
|
29
|
+
"PitStore",
|
|
30
|
+
"parse_and_validate_seed_payload",
|
|
29
31
|
"PitItem",
|
|
30
32
|
"PitItems",
|
|
31
33
|
"OsConfig",
|
|
@@ -20,11 +20,11 @@ from typing import Any
|
|
|
20
20
|
|
|
21
21
|
from .canonical import canonical_json, format_iso_timestamp, parse_iso_timestamp, utcnow
|
|
22
22
|
from .config import OsConfig, loads_json5
|
|
23
|
-
from .exceptions import JsonPitError, PitNotFoundError
|
|
23
|
+
from .exceptions import JsonPitError, PitNotFoundError, ProtectedAttributeError
|
|
24
24
|
from .fs import resolve_pit_target
|
|
25
25
|
from .item import PitItem
|
|
26
26
|
from .icons import Icons
|
|
27
|
-
from .store import Pit
|
|
27
|
+
from .store import Pit, parse_and_validate_seed_payload
|
|
28
28
|
from . import __version__
|
|
29
29
|
|
|
30
30
|
|
|
@@ -380,47 +380,37 @@ def cmd_put(args: argparse.Namespace) -> int:
|
|
|
380
380
|
"""
|
|
381
381
|
Ingests entities from stdin, piped jq output, or a JSON5 file.
|
|
382
382
|
Adds entities cloud-safely with leasing.
|
|
383
|
+
CR043: Supports single root entity, keyed entity map, or entity array.
|
|
383
384
|
"""
|
|
385
|
+
source_path_str = getattr(args, "source", None)
|
|
386
|
+
if not source_path_str and getattr(args, "source_opt", None):
|
|
387
|
+
source_path_str = args.source_opt
|
|
388
|
+
if not source_path_str:
|
|
389
|
+
source_path_str = "-"
|
|
390
|
+
|
|
384
391
|
# Read source content
|
|
385
392
|
content = ""
|
|
386
|
-
if
|
|
387
|
-
path = Path(
|
|
393
|
+
if source_path_str != "-":
|
|
394
|
+
path = Path(source_path_str)
|
|
388
395
|
if not path.is_file():
|
|
389
396
|
sys.stderr.write(f"[jpit] Source file not found: {path}\n")
|
|
390
397
|
return 1
|
|
391
398
|
content = path.read_text(encoding="utf-8")
|
|
399
|
+
source_name = str(path)
|
|
392
400
|
else:
|
|
393
401
|
content = sys.stdin.read()
|
|
402
|
+
source_name = "<stdin>"
|
|
394
403
|
|
|
395
|
-
|
|
396
|
-
|
|
404
|
+
# CR043 Pre-flight validation strictly before opening target Pit or creating directories
|
|
405
|
+
try:
|
|
406
|
+
items_to_add = parse_and_validate_seed_payload(content, source_name)
|
|
407
|
+
except (ValueError, ProtectedAttributeError) as ex:
|
|
408
|
+
sys.stderr.write(f"[jpit] Error: {ex}\n")
|
|
397
409
|
return 1
|
|
398
410
|
|
|
399
|
-
data = loads_json5(content)
|
|
400
|
-
items_to_add: list[dict[str, Any]] = []
|
|
401
|
-
if isinstance(data, list):
|
|
402
|
-
items_to_add = [row for row in data if isinstance(row, dict)]
|
|
403
|
-
elif isinstance(data, dict):
|
|
404
|
-
if "Id" in data or "id" in data:
|
|
405
|
-
items_to_add = [data]
|
|
406
|
-
else:
|
|
407
|
-
for k, v in data.items():
|
|
408
|
-
if isinstance(v, dict):
|
|
409
|
-
v["Id"] = k
|
|
410
|
-
items_to_add.append(v)
|
|
411
|
-
|
|
412
411
|
if not items_to_add:
|
|
413
|
-
|
|
414
|
-
return
|
|
415
|
-
|
|
416
|
-
for raw_obj in items_to_add:
|
|
417
|
-
forbidden = [k for k in raw_obj if k.lower() in ("modified", "deleted")]
|
|
418
|
-
if forbidden:
|
|
419
|
-
sys.stderr.write(
|
|
420
|
-
f"[jpit] Error: Cannot manually update protected attribute '{forbidden[0]}'. "
|
|
421
|
-
"Use 'jpit del' to delete an entity.\n"
|
|
422
|
-
)
|
|
423
|
-
return 1
|
|
412
|
+
print(f"[jpit] Seed source '{source_name}' contained 0 entities.")
|
|
413
|
+
return 0
|
|
424
414
|
|
|
425
415
|
with Pit.open(
|
|
426
416
|
args.pit,
|
|
@@ -430,10 +420,7 @@ def cmd_put(args: argparse.Namespace) -> int:
|
|
|
430
420
|
) as pit:
|
|
431
421
|
count = 0
|
|
432
422
|
for raw_obj in items_to_add:
|
|
433
|
-
|
|
434
|
-
if not item_id:
|
|
435
|
-
continue
|
|
436
|
-
item = PitItem(raw_obj, id=str(item_id))
|
|
423
|
+
item = PitItem(raw_obj)
|
|
437
424
|
pit.add(item)
|
|
438
425
|
count += 1
|
|
439
426
|
|
|
@@ -712,7 +699,7 @@ def print_top_help(
|
|
|
712
699
|
("get", "<PitName> <ItemId> [--at <ts>] [--jq <expr>] [--with-deleted]"),
|
|
713
700
|
("history", "<PitName> <ItemId> [--jq <expr>]"),
|
|
714
701
|
("list", "[<PitName>] [--json] [--jq <expr>] (lists pits if <PitName> omitted)"),
|
|
715
|
-
("put", "<PitName> [<source>]"),
|
|
702
|
+
("put", "<PitName> [<source>] [-s <source>] (alias: seed)"),
|
|
716
703
|
("set", "<PitName> <ItemId> <payload>"),
|
|
717
704
|
("del", "<PitName> <ItemId> [--by <author>]"),
|
|
718
705
|
("del-prop", "<PitName> <ItemId> <PropertyPath>"),
|
|
@@ -774,6 +761,7 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
|
|
|
774
761
|
"delete": "del",
|
|
775
762
|
"delete-item": "del",
|
|
776
763
|
"delete-property": "del-prop",
|
|
764
|
+
"seed": "put",
|
|
777
765
|
}
|
|
778
766
|
canonical_cmd = alias_map.get(cmd, cmd)
|
|
779
767
|
|
|
@@ -839,13 +827,16 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
|
|
|
839
827
|
],
|
|
840
828
|
},
|
|
841
829
|
"put": {
|
|
842
|
-
"usage": "jpit put <PitName> [<source>] [options]",
|
|
843
|
-
"desc": "Ingest JSON5 entities from file or stdin pipe.",
|
|
830
|
+
"usage": "jpit put <PitName> [<source>] [-s <source>] [options]",
|
|
831
|
+
"desc": "Ingest JSON5 entities from file or stdin pipe (alias: seed).",
|
|
844
832
|
"args": [
|
|
845
833
|
("PitName", "Pit name or file path"),
|
|
846
834
|
("source", "Source file or '-' for stdin (default: '-')"),
|
|
847
835
|
],
|
|
848
|
-
"opts": [
|
|
836
|
+
"opts": [
|
|
837
|
+
("-s, --source <file>", "Source file for import (JSON or JSON5)"),
|
|
838
|
+
],
|
|
839
|
+
"aliases": ["seed"],
|
|
849
840
|
},
|
|
850
841
|
"set": {
|
|
851
842
|
"usage": "jpit set <PitName> <ItemId> <payload> [options]",
|
|
@@ -1083,10 +1074,16 @@ def build_parser() -> JsonPitArgumentParser:
|
|
|
1083
1074
|
p_pits = subparsers.add_parser("pits", parents=[common_parser], help="Discover available pits under a tenant root (-r is required)")
|
|
1084
1075
|
p_pits.add_argument("--json", action="store_true", help="Output JSON array of pit names")
|
|
1085
1076
|
|
|
1086
|
-
# put
|
|
1087
|
-
p_put = subparsers.add_parser(
|
|
1077
|
+
# put / seed
|
|
1078
|
+
p_put = subparsers.add_parser(
|
|
1079
|
+
"put",
|
|
1080
|
+
aliases=["seed"],
|
|
1081
|
+
parents=[common_parser],
|
|
1082
|
+
help="Ingest JSON5 entities from file or stdin pipe (alias: seed)",
|
|
1083
|
+
)
|
|
1088
1084
|
p_put.add_argument("pit", help="Pit name")
|
|
1089
|
-
p_put.add_argument("source", nargs="?", default=
|
|
1085
|
+
p_put.add_argument("source", nargs="?", default=None, help="Source file or '-' for stdin")
|
|
1086
|
+
p_put.add_argument("-s", "--source", dest="source_opt", help="Source file for import (JSON or JSON5)")
|
|
1090
1087
|
|
|
1091
1088
|
# set
|
|
1092
1089
|
p_set = subparsers.add_parser("set", parents=[common_parser], help="Set or patch entity with JSON5 payload")
|
|
@@ -1146,7 +1143,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1146
1143
|
cloud, root = extract_cloud_and_root(argv)
|
|
1147
1144
|
|
|
1148
1145
|
known_subcommands = {
|
|
1149
|
-
"grep", "get", "history", "list", "put", "set", "del",
|
|
1146
|
+
"grep", "get", "history", "list", "put", "seed", "set", "del",
|
|
1150
1147
|
"delete", "delete-item", "del-prop", "delete-property",
|
|
1151
1148
|
"rename", "export", "status", "pits",
|
|
1152
1149
|
}
|
|
@@ -1184,6 +1181,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1184
1181
|
"list": cmd_list,
|
|
1185
1182
|
"pits": cmd_pits,
|
|
1186
1183
|
"put": cmd_put,
|
|
1184
|
+
"seed": cmd_put,
|
|
1187
1185
|
"set": cmd_set,
|
|
1188
1186
|
"del": cmd_delete,
|
|
1189
1187
|
"delete": cmd_delete,
|
|
@@ -245,6 +245,21 @@ class PitItem(MutableMapping[str, Any]):
|
|
|
245
245
|
|
|
246
246
|
return self.extend_with(patch_dict)
|
|
247
247
|
|
|
248
|
+
@staticmethod
|
|
249
|
+
def validate_client_payload(payload: dict[str, Any]) -> None:
|
|
250
|
+
"""
|
|
251
|
+
Validates a client entity payload before live ingestion.
|
|
252
|
+
CR040: Lifecycle attributes (Modified, Deleted) are reserved for historical replay.
|
|
253
|
+
"""
|
|
254
|
+
if not isinstance(payload, dict):
|
|
255
|
+
raise ValueError("Client payload must evaluate to a dictionary.")
|
|
256
|
+
for k in payload:
|
|
257
|
+
if k.lower() in ("modified", "deleted"):
|
|
258
|
+
raise ProtectedAttributeError(
|
|
259
|
+
f"Cannot manually update protected attribute '{k}'. "
|
|
260
|
+
"Use 'jpit del' to delete an entity."
|
|
261
|
+
)
|
|
262
|
+
|
|
248
263
|
def delete_property(self, property_name: str) -> None:
|
|
249
264
|
"""
|
|
250
265
|
Appends a property tombstone by setting the top-level property to None (JSON null).
|
|
@@ -28,6 +28,7 @@ from .exceptions import (
|
|
|
28
28
|
PitCorruptError,
|
|
29
29
|
PitInstanceConflictError,
|
|
30
30
|
PitNotFoundError,
|
|
31
|
+
ProtectedAttributeError,
|
|
31
32
|
)
|
|
32
33
|
from .flags import (
|
|
33
34
|
MasterFlagFile,
|
|
@@ -49,6 +50,71 @@ from .history import PitItems
|
|
|
49
50
|
from .item import PitItem
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
def has_non_empty_string_id(item: Any) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
CR043: Checks if an item is a dictionary with an exact non-empty string 'Id'.
|
|
56
|
+
Lowercase 'id' or non-string/whitespace values are not accepted.
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(item, dict):
|
|
59
|
+
return False
|
|
60
|
+
val = item.get("Id")
|
|
61
|
+
return isinstance(val, str) and bool(val.strip())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_and_validate_seed_payload(
|
|
65
|
+
payload: str | bytes | Any,
|
|
66
|
+
source_name: str = "source",
|
|
67
|
+
) -> list[dict[str, Any]]:
|
|
68
|
+
"""
|
|
69
|
+
CR043: Parses and validates a seed payload strictly BEFORE opening target Pit.
|
|
70
|
+
Three accepted shapes:
|
|
71
|
+
1. Single Entity Object with an exact, non-empty string 'Id'
|
|
72
|
+
2. Keyed Entity Map where all values are entity dictionaries
|
|
73
|
+
3. Entity Array of dictionaries
|
|
74
|
+
Every individual entity must have an exact, non-empty string 'Id' and pass client payload validation.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(payload, (str, bytes)):
|
|
77
|
+
text = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
78
|
+
if not text.strip():
|
|
79
|
+
raise ValueError(f"Source '{source_name}' is empty.")
|
|
80
|
+
try:
|
|
81
|
+
root = loads_json5(text)
|
|
82
|
+
except Exception as ex:
|
|
83
|
+
raise ValueError(f"Source '{source_name}' contains invalid JSON: {ex}") from ex
|
|
84
|
+
else:
|
|
85
|
+
root = payload
|
|
86
|
+
|
|
87
|
+
shape_diagnostic = (
|
|
88
|
+
f"Source '{source_name}' must be a JSON array of entities, a single entity object "
|
|
89
|
+
f"with a non-empty 'Id', or a keyed map of entity objects."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
items: list[Any]
|
|
93
|
+
if isinstance(root, list):
|
|
94
|
+
items = root
|
|
95
|
+
elif isinstance(root, dict) and has_non_empty_string_id(root):
|
|
96
|
+
items = [root]
|
|
97
|
+
elif isinstance(root, dict) and all(isinstance(v, dict) for v in root.values()):
|
|
98
|
+
items = list(root.values())
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(shape_diagnostic)
|
|
101
|
+
|
|
102
|
+
validated: list[dict[str, Any]] = []
|
|
103
|
+
for item in items:
|
|
104
|
+
if not isinstance(item, dict):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"{shape_diagnostic} Arrays and keyed maps may contain only JSON objects."
|
|
107
|
+
)
|
|
108
|
+
if not has_non_empty_string_id(item):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Source '{source_name}' contains an entity without a non-empty string 'Id'."
|
|
111
|
+
)
|
|
112
|
+
PitItem.validate_client_payload(item)
|
|
113
|
+
validated.append(item)
|
|
114
|
+
|
|
115
|
+
return validated
|
|
116
|
+
|
|
117
|
+
|
|
52
118
|
class JsonPitBase:
|
|
53
119
|
"""
|
|
54
120
|
Common base for pit storage with leasing, identity, and persistence coordination.
|
|
@@ -355,35 +421,20 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
|
|
|
355
421
|
"""
|
|
356
422
|
Ingests a seed file (JSON or JSON5) and adds all entities to the pit.
|
|
357
423
|
Returns count of imported entities.
|
|
424
|
+
CR043: Validates payload shape and entities strictly before mutation.
|
|
358
425
|
"""
|
|
359
426
|
path = Path(file_path)
|
|
360
427
|
if not path.is_file():
|
|
361
428
|
raise PitNotFoundError(f"Seed file not found: {path}")
|
|
362
429
|
|
|
363
430
|
text = path.read_text(encoding="utf-8")
|
|
364
|
-
|
|
365
|
-
data = loads_json5(text)
|
|
431
|
+
items_to_add = parse_and_validate_seed_payload(text, str(path))
|
|
366
432
|
|
|
367
433
|
count = 0
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
self.add(item)
|
|
373
|
-
count += 1
|
|
374
|
-
elif isinstance(data, dict):
|
|
375
|
-
# Object mapping ID -> entity or single entity
|
|
376
|
-
if "Id" in data or "id" in data:
|
|
377
|
-
self.add(PitItem(data))
|
|
378
|
-
count = 1
|
|
379
|
-
else:
|
|
380
|
-
for k, v in data.items():
|
|
381
|
-
if isinstance(v, dict):
|
|
382
|
-
row = copy.deepcopy(v)
|
|
383
|
-
if "Id" not in row and "id" not in row:
|
|
384
|
-
row["Id"] = k
|
|
385
|
-
self.add(PitItem(row))
|
|
386
|
-
count += 1
|
|
434
|
+
for raw_obj in items_to_add:
|
|
435
|
+
item = PitItem(raw_obj)
|
|
436
|
+
self.add(item)
|
|
437
|
+
count += 1
|
|
387
438
|
return count
|
|
388
439
|
|
|
389
440
|
# --- Entity Addition & Mutation ---
|
|
@@ -681,3 +732,7 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
|
|
|
681
732
|
|
|
682
733
|
def __repr__(self) -> str:
|
|
683
734
|
return f"<Pit '{self.pit_name}' path={self.pit_dir} items={len(self)}>"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
Pit.parse_and_validate_seed_payload = staticmethod(parse_and_validate_seed_payload) # type: ignore[attr-defined]
|
|
738
|
+
PitStore = Pit
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jsonpit"
|
|
7
|
-
version = "4.4.
|
|
7
|
+
version = "4.4.3"
|
|
8
8
|
description = "Daemon-free distributed storage over Cloud Drives for developers and AI agents — multi-process, immutable history, zero dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|