jsonpit 4.4.2__tar.gz → 4.4.4__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.4
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,14 +31,14 @@ 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.4`)
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.4`** in direct lockstep with C# `JsonPit` and `pits 4.4.4` 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.
40
40
  - **Elimination of Multi-Language Matrix Confusion:** In heterogeneous environments (e.g. C# backend daemons paired with Python agent orchestrators and data science tooling), unified SemVer tags eliminate complex cross-compatibility lookup tables.
41
- - **Joint Protocol Governance:** Architectural changes (such as CR040 anti-Read-Modify-Write rules and CR041 clean change file formatting) are certified across both languages under the exact same version milestone before release.
41
+ - **Joint Protocol Governance:** Architectural changes (such as CR040 anti-Read-Modify-Write rules, CR041 clean change file formatting, and CR044 missing configuration diagnostics) are certified across both languages under the exact same version milestone before release.
42
42
 
43
43
  ---
44
44
 
@@ -54,6 +54,69 @@ The canonical **reference (lead) implementation** is C# `JsonPit` and the `pits`
54
54
 
55
55
  ---
56
56
 
57
+ ## Installation & Upgrade
58
+
59
+ ### As a Global CLI Tool (`jpit`) via `pipx` (Recommended)
60
+ To run the `jpit` command-line companion anywhere across your machine in an isolated environment without polluting system packages:
61
+
62
+ ```bash
63
+ # Install globally
64
+ pipx install jsonpit
65
+ # (or via companion alias)
66
+ pipx install jpit
67
+
68
+ # Upgrade to the latest lockstep release (v4.4.4)
69
+ pipx upgrade jsonpit
70
+ # (or)
71
+ pipx upgrade jpit
72
+ ```
73
+
74
+ ### In a Project / Virtual Environment via `pip`
75
+ To embed `jsonpit` into your Python applications, data science workflows, or agentic runtimes:
76
+
77
+ ```bash
78
+ # Install
79
+ pip install jsonpit
80
+
81
+ # Upgrade to the latest release
82
+ pip install --upgrade jsonpit
83
+ ```
84
+
85
+ ### Upgrading with Multi-Python / `pyenv` Environments
86
+ If you maintain multiple Python versions on your machine (e.g. 3.12, 3.13, 3.14):
87
+
88
+ 1. **Always anchor `pip` to your active Python interpreter:**
89
+ ```bash
90
+ python -m pip install --upgrade jpit
91
+ ```
92
+ *(Avoid bare `pip install`: invoking `pip` directly may resolve to an older Python version in `$PATH`, installing the package into the wrong runtime).*
93
+
94
+ 2. **If using `pyenv`, regenerate shims:**
95
+ ```bash
96
+ pyenv rehash
97
+ ```
98
+ *This ensures `which jpit` resolves to `~/.pyenv/shims/jpit`, which dynamically routes to whichever Python version is currently active in your shell.*
99
+
100
+ 3. **Verify the active executable:**
101
+ ```bash
102
+ which jpit
103
+ jpit --version
104
+ ```
105
+
106
+ > [!TIP]
107
+ > **Encountering `error: externally-managed-environment` (Homebrew Python or modern Linux / PEP 668)?**
108
+ > Modern Homebrew and Linux package managers protect their system Python prefix by default. You have two clean options:
109
+ > - **Option A (Isolated CLI via `pipx`):** Run `brew install pipx && pipx ensurepath`, then `pipx install jpit`.
110
+ > - **Option B (Direct install into Homebrew Python):** Pass `--break-system-packages`:
111
+ > ```bash
112
+ > python -m pip install --upgrade --break-system-packages jpit
113
+ > ```
114
+ > *(Completely safe: `jsonpit` has zero runtime dependencies and cannot break system packages).*
115
+
116
+ *(Both the `jsonpit` library package and the `jpit` companion package provide both `jpit` and `jsonpit` command-line executables).*
117
+
118
+ ---
119
+
57
120
  ## Quickstart
58
121
 
59
122
  ### 1. Cloud-First Triad (The Standard Pattern)
@@ -9,14 +9,14 @@ 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.4`)
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.4`** in direct lockstep with C# `JsonPit` and `pits 4.4.4` 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.
18
18
  - **Elimination of Multi-Language Matrix Confusion:** In heterogeneous environments (e.g. C# backend daemons paired with Python agent orchestrators and data science tooling), unified SemVer tags eliminate complex cross-compatibility lookup tables.
19
- - **Joint Protocol Governance:** Architectural changes (such as CR040 anti-Read-Modify-Write rules and CR041 clean change file formatting) are certified across both languages under the exact same version milestone before release.
19
+ - **Joint Protocol Governance:** Architectural changes (such as CR040 anti-Read-Modify-Write rules, CR041 clean change file formatting, and CR044 missing configuration diagnostics) are certified across both languages under the exact same version milestone before release.
20
20
 
21
21
  ---
22
22
 
@@ -32,6 +32,69 @@ The canonical **reference (lead) implementation** is C# `JsonPit` and the `pits`
32
32
 
33
33
  ---
34
34
 
35
+ ## Installation & Upgrade
36
+
37
+ ### As a Global CLI Tool (`jpit`) via `pipx` (Recommended)
38
+ To run the `jpit` command-line companion anywhere across your machine in an isolated environment without polluting system packages:
39
+
40
+ ```bash
41
+ # Install globally
42
+ pipx install jsonpit
43
+ # (or via companion alias)
44
+ pipx install jpit
45
+
46
+ # Upgrade to the latest lockstep release (v4.4.4)
47
+ pipx upgrade jsonpit
48
+ # (or)
49
+ pipx upgrade jpit
50
+ ```
51
+
52
+ ### In a Project / Virtual Environment via `pip`
53
+ To embed `jsonpit` into your Python applications, data science workflows, or agentic runtimes:
54
+
55
+ ```bash
56
+ # Install
57
+ pip install jsonpit
58
+
59
+ # Upgrade to the latest release
60
+ pip install --upgrade jsonpit
61
+ ```
62
+
63
+ ### Upgrading with Multi-Python / `pyenv` Environments
64
+ If you maintain multiple Python versions on your machine (e.g. 3.12, 3.13, 3.14):
65
+
66
+ 1. **Always anchor `pip` to your active Python interpreter:**
67
+ ```bash
68
+ python -m pip install --upgrade jpit
69
+ ```
70
+ *(Avoid bare `pip install`: invoking `pip` directly may resolve to an older Python version in `$PATH`, installing the package into the wrong runtime).*
71
+
72
+ 2. **If using `pyenv`, regenerate shims:**
73
+ ```bash
74
+ pyenv rehash
75
+ ```
76
+ *This ensures `which jpit` resolves to `~/.pyenv/shims/jpit`, which dynamically routes to whichever Python version is currently active in your shell.*
77
+
78
+ 3. **Verify the active executable:**
79
+ ```bash
80
+ which jpit
81
+ jpit --version
82
+ ```
83
+
84
+ > [!TIP]
85
+ > **Encountering `error: externally-managed-environment` (Homebrew Python or modern Linux / PEP 668)?**
86
+ > Modern Homebrew and Linux package managers protect their system Python prefix by default. You have two clean options:
87
+ > - **Option A (Isolated CLI via `pipx`):** Run `brew install pipx && pipx ensurepath`, then `pipx install jpit`.
88
+ > - **Option B (Direct install into Homebrew Python):** Pass `--break-system-packages`:
89
+ > ```bash
90
+ > python -m pip install --upgrade --break-system-packages jpit
91
+ > ```
92
+ > *(Completely safe: `jsonpit` has zero runtime dependencies and cannot break system packages).*
93
+
94
+ *(Both the `jsonpit` library package and the `jpit` companion package provide both `jpit` and `jsonpit` command-line executables).*
95
+
96
+ ---
97
+
35
98
  ## Quickstart
36
99
 
37
100
  ### 1. Cloud-First Triad (The Standard Pattern)
@@ -12,23 +12,27 @@ from .exceptions import (
12
12
  ProtectedAttributeError,
13
13
  TombstoneError,
14
14
  )
15
- from .fs import OsConfig
15
+ from .config import DEFAULT_CONFIG_FILE_LOCATION, OsConfig, missing_configuration_diagnostic
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.4"
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",
34
+ "DEFAULT_CONFIG_FILE_LOCATION",
35
+ "missing_configuration_diagnostic",
32
36
  "Icons",
33
37
  "JsonPitError",
34
38
  "PitNotFoundError",
@@ -19,12 +19,12 @@ import sys
19
19
  from typing import Any
20
20
 
21
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
22
+ from .config import OsConfig, loads_json5, missing_configuration_diagnostic
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
 
@@ -70,21 +70,39 @@ def discover_pits(
70
70
  return discovered_cwd
71
71
 
72
72
  # Check if target is a single pit or an explicit directory
73
- try:
74
- p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
75
- if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
76
- return [(p_dir, p_name)]
77
- except Exception:
78
- pass
79
-
80
- # Check if target is a tenant/root folder containing multiple pit directories
73
+ raw_str = str(target).strip()
74
+ is_explicit_path = (
75
+ raw_str.startswith(("/", "~", ".", "\\"))
76
+ or ("/" in raw_str and not raw_str.startswith("-"))
77
+ or ("\\" in raw_str)
78
+ or Path(raw_str).exists()
79
+ )
80
+ if is_explicit_path:
81
+ try:
82
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
83
+ if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
84
+ return [(p_dir, p_name)]
85
+ except Exception:
86
+ pass
87
+ if Path(target).is_dir():
88
+ discovered = []
89
+ for child in sorted(Path(target).iterdir()):
90
+ if child.is_dir():
91
+ pit_file = child / f"{child.name}.pit"
92
+ if pit_file.is_file():
93
+ discovered.append((child, child.name))
94
+ if discovered:
95
+ return discovered
96
+
97
+ # Target requires cloud resolution
81
98
  cfg = OsConfig.load()
99
+ if not cfg.is_config_loaded and not is_explicit_path:
100
+ raise PitNotFoundError(missing_configuration_diagnostic())
101
+
82
102
  cloud_root = cfg.get_cloud_root(cloud)
83
103
  candidates: list[Path] = []
84
104
  if cloud_root and (cloud_root / target).is_dir():
85
105
  candidates.append(cloud_root / target)
86
- if Path(target).is_dir():
87
- candidates.append(Path(target))
88
106
 
89
107
  discovered: list[tuple[Path, str]] = []
90
108
  for candidate_root in candidates:
@@ -97,13 +115,10 @@ def discover_pits(
97
115
  if discovered:
98
116
  return discovered
99
117
 
100
- # Fallback single pit target (only return if it physically exists)
101
- try:
102
- p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
103
- if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
104
- return [(p_dir, p_name)]
105
- except Exception:
106
- pass
118
+ # Fallback single pit target
119
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
120
+ if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
121
+ return [(p_dir, p_name)]
107
122
  return []
108
123
 
109
124
 
@@ -111,17 +126,16 @@ def discover_root_pits(root: str | None, cloud: str | None = "OneDrive") -> list
111
126
  """Discovers all pit names under a resolved tenant root directory."""
112
127
  if not root:
113
128
  return []
114
- cfg = OsConfig.load()
115
- cloud_root = cfg.get_cloud_root(cloud or "OneDrive")
116
- candidate: Path | None = None
117
- if cloud_root and (cloud_root / root).is_dir():
118
- candidate = cloud_root / root
129
+ raw_str = str(root).strip()
130
+ p = Path(os.path.expanduser(raw_str))
131
+ if p.is_dir():
132
+ candidate: Path | None = p
119
133
  else:
120
- p = Path(os.path.expanduser(root))
121
- if p.is_dir():
122
- candidate = p
123
- elif cloud_root:
124
- candidate = cloud_root / root
134
+ cfg = OsConfig.load()
135
+ if not cfg.is_config_loaded:
136
+ raise PitNotFoundError(missing_configuration_diagnostic())
137
+ cloud_root = cfg.get_cloud_root(cloud or "OneDrive")
138
+ candidate = cloud_root / root if cloud_root else None
125
139
 
126
140
  if not candidate or not candidate.is_dir():
127
141
  return []
@@ -380,47 +394,37 @@ def cmd_put(args: argparse.Namespace) -> int:
380
394
  """
381
395
  Ingests entities from stdin, piped jq output, or a JSON5 file.
382
396
  Adds entities cloud-safely with leasing.
397
+ CR043: Supports single root entity, keyed entity map, or entity array.
383
398
  """
399
+ source_path_str = getattr(args, "source", None)
400
+ if not source_path_str and getattr(args, "source_opt", None):
401
+ source_path_str = args.source_opt
402
+ if not source_path_str:
403
+ source_path_str = "-"
404
+
384
405
  # Read source content
385
406
  content = ""
386
- if args.source and args.source != "-":
387
- path = Path(args.source)
407
+ if source_path_str != "-":
408
+ path = Path(source_path_str)
388
409
  if not path.is_file():
389
410
  sys.stderr.write(f"[jpit] Source file not found: {path}\n")
390
411
  return 1
391
412
  content = path.read_text(encoding="utf-8")
413
+ source_name = str(path)
392
414
  else:
393
415
  content = sys.stdin.read()
416
+ source_name = "<stdin>"
394
417
 
395
- if not content.strip():
396
- sys.stderr.write("[jpit] Error: No input data provided.\n")
418
+ # CR043 Pre-flight validation strictly before opening target Pit or creating directories
419
+ try:
420
+ items_to_add = parse_and_validate_seed_payload(content, source_name)
421
+ except (ValueError, ProtectedAttributeError) as ex:
422
+ sys.stderr.write(f"[jpit] Error: {ex}\n")
397
423
  return 1
398
424
 
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
425
  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
426
+ print(f"[jpit] Seed source '{source_name}' contained 0 entities.")
427
+ return 0
424
428
 
425
429
  with Pit.open(
426
430
  args.pit,
@@ -430,10 +434,7 @@ def cmd_put(args: argparse.Namespace) -> int:
430
434
  ) as pit:
431
435
  count = 0
432
436
  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))
437
+ item = PitItem(raw_obj)
437
438
  pit.add(item)
438
439
  count += 1
439
440
 
@@ -712,7 +713,7 @@ def print_top_help(
712
713
  ("get", "<PitName> <ItemId> [--at <ts>] [--jq <expr>] [--with-deleted]"),
713
714
  ("history", "<PitName> <ItemId> [--jq <expr>]"),
714
715
  ("list", "[<PitName>] [--json] [--jq <expr>] (lists pits if <PitName> omitted)"),
715
- ("put", "<PitName> [<source>]"),
716
+ ("put", "<PitName> [<source>] [-s <source>] (alias: seed)"),
716
717
  ("set", "<PitName> <ItemId> <payload>"),
717
718
  ("del", "<PitName> <ItemId> [--by <author>]"),
718
719
  ("del-prop", "<PitName> <ItemId> <PropertyPath>"),
@@ -748,7 +749,10 @@ def print_top_help(
748
749
  # Dynamic Pits Discovery line
749
750
  pits_title = color(f"{Icons.INFO} PitNames", C_CYAN, use_color)
750
751
  if root:
751
- discovered = discover_root_pits(root, cloud)
752
+ try:
753
+ discovered = discover_root_pits(root, cloud)
754
+ except Exception:
755
+ discovered = []
752
756
  if discovered:
753
757
  formatted_pits = []
754
758
  for idx, p in enumerate(discovered):
@@ -774,6 +778,7 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
774
778
  "delete": "del",
775
779
  "delete-item": "del",
776
780
  "delete-property": "del-prop",
781
+ "seed": "put",
777
782
  }
778
783
  canonical_cmd = alias_map.get(cmd, cmd)
779
784
 
@@ -839,13 +844,16 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
839
844
  ],
840
845
  },
841
846
  "put": {
842
- "usage": "jpit put <PitName> [<source>] [options]",
843
- "desc": "Ingest JSON5 entities from file or stdin pipe.",
847
+ "usage": "jpit put <PitName> [<source>] [-s <source>] [options]",
848
+ "desc": "Ingest JSON5 entities from file or stdin pipe (alias: seed).",
844
849
  "args": [
845
850
  ("PitName", "Pit name or file path"),
846
851
  ("source", "Source file or '-' for stdin (default: '-')"),
847
852
  ],
848
- "opts": [],
853
+ "opts": [
854
+ ("-s, --source <file>", "Source file for import (JSON or JSON5)"),
855
+ ],
856
+ "aliases": ["seed"],
849
857
  },
850
858
  "set": {
851
859
  "usage": "jpit set <PitName> <ItemId> <payload> [options]",
@@ -1083,10 +1091,16 @@ def build_parser() -> JsonPitArgumentParser:
1083
1091
  p_pits = subparsers.add_parser("pits", parents=[common_parser], help="Discover available pits under a tenant root (-r is required)")
1084
1092
  p_pits.add_argument("--json", action="store_true", help="Output JSON array of pit names")
1085
1093
 
1086
- # put
1087
- p_put = subparsers.add_parser("put", parents=[common_parser], help="Ingest JSON5 entities from file or stdin pipe")
1094
+ # put / seed
1095
+ p_put = subparsers.add_parser(
1096
+ "put",
1097
+ aliases=["seed"],
1098
+ parents=[common_parser],
1099
+ help="Ingest JSON5 entities from file or stdin pipe (alias: seed)",
1100
+ )
1088
1101
  p_put.add_argument("pit", help="Pit name")
1089
- p_put.add_argument("source", nargs="?", default="-", help="Source file or '-' for stdin")
1102
+ p_put.add_argument("source", nargs="?", default=None, help="Source file or '-' for stdin")
1103
+ p_put.add_argument("-s", "--source", dest="source_opt", help="Source file for import (JSON or JSON5)")
1090
1104
 
1091
1105
  # set
1092
1106
  p_set = subparsers.add_parser("set", parents=[common_parser], help="Set or patch entity with JSON5 payload")
@@ -1146,7 +1160,7 @@ def main(argv: list[str] | None = None) -> int:
1146
1160
  cloud, root = extract_cloud_and_root(argv)
1147
1161
 
1148
1162
  known_subcommands = {
1149
- "grep", "get", "history", "list", "put", "set", "del",
1163
+ "grep", "get", "history", "list", "put", "seed", "set", "del",
1150
1164
  "delete", "delete-item", "del-prop", "delete-property",
1151
1165
  "rename", "export", "status", "pits",
1152
1166
  }
@@ -1184,6 +1198,7 @@ def main(argv: list[str] | None = None) -> int:
1184
1198
  "list": cmd_list,
1185
1199
  "pits": cmd_pits,
1186
1200
  "put": cmd_put,
1201
+ "seed": cmd_put,
1187
1202
  "set": cmd_set,
1188
1203
  "del": cmd_delete,
1189
1204
  "delete": cmd_delete,
@@ -217,6 +217,20 @@ def loads_json5(text: str) -> Any:
217
217
  return parse_value()
218
218
 
219
219
 
220
+ DEFAULT_CONFIG_FILE_LOCATION: str = "~/.config/RAIkeep.json5"
221
+
222
+
223
+ def missing_configuration_diagnostic() -> str:
224
+ """
225
+ CR044 §7.4: Standardized diagnostic when RAIkeep configuration is not found.
226
+ Directs the operator to 'amafu init'.
227
+ """
228
+ return (
229
+ f"RAIkeep configuration was not found at '{DEFAULT_CONFIG_FILE_LOCATION}'. "
230
+ "Run 'amafu init' to detect cloud providers and create it."
231
+ )
232
+
233
+
220
234
  class OsConfig:
221
235
  """
222
236
  System configuration derived from ~/.config/RAIkeep.json5 or fallback files.
@@ -253,6 +267,16 @@ class OsConfig:
253
267
  if path_str:
254
268
  self.clouds[name] = self._normalize_path(str(path_str))
255
269
 
270
+ @property
271
+ def is_config_loaded(self) -> bool:
272
+ """Returns True if configuration was loaded from a file or explicit dictionary."""
273
+ return self._config_path is not None or bool(self.clouds)
274
+
275
+ @classmethod
276
+ def reset(cls) -> None:
277
+ """Resets the singleton instance (used in tests)."""
278
+ cls._instance = None
279
+
256
280
  @staticmethod
257
281
  def _normalize_path(p: str) -> Path:
258
282
  expanded = os.path.expanduser(p.strip())
@@ -11,7 +11,7 @@ import shutil
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- from .config import OsConfig
14
+ from .config import DEFAULT_CONFIG_FILE_LOCATION, OsConfig, missing_configuration_diagnostic
15
15
  from .exceptions import PitNotFoundError
16
16
 
17
17
 
@@ -52,12 +52,16 @@ def resolve_pit_target(
52
52
  pit_name = resolved.name
53
53
  return pit_dir, pit_name
54
54
 
55
+ # CR044 §7.4: If attempting cloud triad resolution and no configuration file exists
56
+ if not cfg.is_config_loaded:
57
+ raise PitNotFoundError(missing_configuration_diagnostic())
58
+
55
59
  # Cloud triad resolution (e.g. name="Person", cloud="OneDrive", root="AfricaStage")
56
60
  cloud_root = cfg.get_cloud_root(cloud)
57
61
  if cloud_root is None:
58
62
  raise PitNotFoundError(
59
63
  f"Could not resolve cloud storage root for cloud '{cloud or 'default'}'. "
60
- "Ensure ~/.config/RAIkeep.json5 or ~/.config/jsonpit.json5 is configured."
64
+ f"Ensure {DEFAULT_CONFIG_FILE_LOCATION} or ~/.config/jsonpit.json5 is configured."
61
65
  )
62
66
 
63
67
  pit_name = raw_str
@@ -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.4"
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