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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jsonpit
3
- Version: 4.4.2
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.2`)
34
+ ## Cross-Tool & Cross-Language Version Alignment (`v4.4.3`)
35
35
 
36
- `jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.2`** in direct lockstep with C# `JsonPit` and `pits 4.4.2` in [RAIkeep](https://github.com/Burkhardt/RAIkeep).
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.2`)
12
+ ## Cross-Tool & Cross-Language Version Alignment (`v4.4.3`)
13
13
 
14
- `jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.2`** in direct lockstep with C# `JsonPit` and `pits 4.4.2` in [RAIkeep](https://github.com/Burkhardt/RAIkeep).
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.2"
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 args.source and args.source != "-":
387
- path = Path(args.source)
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
- if not content.strip():
396
- sys.stderr.write("[jpit] Error: No input data provided.\n")
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
- sys.stderr.write("[jpit] Error: Input data contains no valid entity objects.\n")
414
- return 1
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
- item_id = raw_obj.get("Id") or raw_obj.get("id") or raw_obj.get("Name")
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("put", parents=[common_parser], help="Ingest JSON5 entities from file or stdin pipe")
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="-", help="Source file or '-' for stdin")
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
- # Human seeder files may use JSON5 syntax (comments, unquoted keys, trailing commas)
365
- data = loads_json5(text)
431
+ items_to_add = parse_and_validate_seed_payload(text, str(path))
366
432
 
367
433
  count = 0
368
- if isinstance(data, list):
369
- for row in data:
370
- if isinstance(row, dict):
371
- item = PitItem(row)
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.2"
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