jsonpit 4.4.3__tar.gz → 4.4.5__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,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jsonpit
3
- Version: 4.4.3
3
+ Version: 4.4.5
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
7
- Classifier: Development Status :: 4 - Beta
7
+ Classifier: Development Status :: 5 - Production/Stable
8
8
  Classifier: Intended Audience :: Developers
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: Programming Language :: Python :: 3
@@ -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.3`)
34
+ ## Cross-Tool & Cross-Language Version Alignment (`v4.4.5`)
35
35
 
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).
36
+ `jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.5`** in direct lockstep with C# `JsonPit` and `pits 4.4.5` 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, CR044 missing configuration diagnostics, and CR047 strict patch validation) 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.5)
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.3`)
12
+ ## Cross-Tool & Cross-Language Version Alignment (`v4.4.5`)
13
13
 
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).
14
+ `jsonpit` and the `jpit` CLI are version-synchronized to **`v4.4.5`** in direct lockstep with C# `JsonPit` and `pits 4.4.5` 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, CR044 missing configuration diagnostics, and CR047 strict patch validation) 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.5)
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)
@@ -10,19 +10,16 @@ from .exceptions import (
10
10
  PitInstanceConflictError,
11
11
  PitNotFoundError,
12
12
  ProtectedAttributeError,
13
+ StrictPatchValidationError,
13
14
  TombstoneError,
14
15
  )
15
- from .fs import OsConfig
16
+ from .config import DEFAULT_CONFIG_FILE_LOCATION, OsConfig, missing_configuration_diagnostic
16
17
  from .history import PitItems
17
18
  from .icons import Icons
18
19
  from .item import PitItem
19
20
  from .store import Pit, PitStore, parse_and_validate_seed_payload
20
21
 
21
- try:
22
- import importlib.metadata
23
- __version__ = importlib.metadata.version("jsonpit")
24
- except Exception:
25
- __version__ = "4.4.3"
22
+ __version__ = "4.4.5"
26
23
 
27
24
  __all__ = [
28
25
  "Pit",
@@ -31,6 +28,8 @@ __all__ = [
31
28
  "PitItem",
32
29
  "PitItems",
33
30
  "OsConfig",
31
+ "DEFAULT_CONFIG_FILE_LOCATION",
32
+ "missing_configuration_diagnostic",
34
33
  "Icons",
35
34
  "JsonPitError",
36
35
  "PitNotFoundError",
@@ -38,5 +37,6 @@ __all__ = [
38
37
  "PitConcurrencyError",
39
38
  "PitInstanceConflictError",
40
39
  "ProtectedAttributeError",
40
+ "StrictPatchValidationError",
41
41
  "TombstoneError",
42
42
  ]
@@ -19,8 +19,13 @@ 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, ProtectedAttributeError
22
+ from .config import OsConfig, loads_json5, missing_configuration_diagnostic
23
+ from .exceptions import (
24
+ JsonPitError,
25
+ PitNotFoundError,
26
+ ProtectedAttributeError,
27
+ StrictPatchValidationError,
28
+ )
24
29
  from .fs import resolve_pit_target
25
30
  from .item import PitItem
26
31
  from .icons import Icons
@@ -70,21 +75,39 @@ def discover_pits(
70
75
  return discovered_cwd
71
76
 
72
77
  # 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
78
+ raw_str = str(target).strip()
79
+ is_explicit_path = (
80
+ raw_str.startswith(("/", "~", ".", "\\"))
81
+ or ("/" in raw_str and not raw_str.startswith("-"))
82
+ or ("\\" in raw_str)
83
+ or Path(raw_str).exists()
84
+ )
85
+ if is_explicit_path:
86
+ try:
87
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
88
+ if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
89
+ return [(p_dir, p_name)]
90
+ except Exception:
91
+ pass
92
+ if Path(target).is_dir():
93
+ discovered = []
94
+ for child in sorted(Path(target).iterdir()):
95
+ if child.is_dir():
96
+ pit_file = child / f"{child.name}.pit"
97
+ if pit_file.is_file():
98
+ discovered.append((child, child.name))
99
+ if discovered:
100
+ return discovered
101
+
102
+ # Target requires cloud resolution
81
103
  cfg = OsConfig.load()
104
+ if not cfg.is_config_loaded and not is_explicit_path:
105
+ raise PitNotFoundError(missing_configuration_diagnostic())
106
+
82
107
  cloud_root = cfg.get_cloud_root(cloud)
83
108
  candidates: list[Path] = []
84
109
  if cloud_root and (cloud_root / target).is_dir():
85
110
  candidates.append(cloud_root / target)
86
- if Path(target).is_dir():
87
- candidates.append(Path(target))
88
111
 
89
112
  discovered: list[tuple[Path, str]] = []
90
113
  for candidate_root in candidates:
@@ -97,13 +120,10 @@ def discover_pits(
97
120
  if discovered:
98
121
  return discovered
99
122
 
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
123
+ # Fallback single pit target
124
+ p_dir, p_name = resolve_pit_target(target, cloud=cloud, root=root)
125
+ if (p_dir / f"{p_name}.pit").is_file() or p_dir.is_dir():
126
+ return [(p_dir, p_name)]
107
127
  return []
108
128
 
109
129
 
@@ -111,17 +131,16 @@ def discover_root_pits(root: str | None, cloud: str | None = "OneDrive") -> list
111
131
  """Discovers all pit names under a resolved tenant root directory."""
112
132
  if not root:
113
133
  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
134
+ raw_str = str(root).strip()
135
+ p = Path(os.path.expanduser(raw_str))
136
+ if p.is_dir():
137
+ candidate: Path | None = p
119
138
  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
139
+ cfg = OsConfig.load()
140
+ if not cfg.is_config_loaded:
141
+ raise PitNotFoundError(missing_configuration_diagnostic())
142
+ cloud_root = cfg.get_cloud_root(cloud or "OneDrive")
143
+ candidate = cloud_root / root if cloud_root else None
125
144
 
126
145
  if not candidate or not candidate.is_dir():
127
146
  return []
@@ -381,7 +400,9 @@ def cmd_put(args: argparse.Namespace) -> int:
381
400
  Ingests entities from stdin, piped jq output, or a JSON5 file.
382
401
  Adds entities cloud-safely with leasing.
383
402
  CR043: Supports single root entity, keyed entity map, or entity array.
403
+ CR047: Strict patch mode via --require-existing or --patch.
384
404
  """
405
+ require_existing = bool(getattr(args, "require_existing", False) or getattr(args, "patch", False))
385
406
  source_path_str = getattr(args, "source", None)
386
407
  if not source_path_str and getattr(args, "source_opt", None):
387
408
  source_path_str = args.source_opt
@@ -408,10 +429,42 @@ def cmd_put(args: argparse.Namespace) -> int:
408
429
  sys.stderr.write(f"[jpit] Error: {ex}\n")
409
430
  return 1
410
431
 
432
+ if require_existing and not items_to_add:
433
+ sys.stderr.write("error: Patch source contained 0 entities.\n")
434
+ return 1
435
+
411
436
  if not items_to_add:
412
437
  print(f"[jpit] Seed source '{source_name}' contained 0 entities.")
413
438
  return 0
414
439
 
440
+ # CR047: Strict patch mode pre-validation before opening target Pit for writing or creating flags
441
+ if require_existing:
442
+ try:
443
+ with Pit.open(
444
+ args.pit,
445
+ cloud=args.cloud,
446
+ root=args.root,
447
+ read_only=True,
448
+ unflagged=True,
449
+ ) as living_state:
450
+ for raw_obj in items_to_add:
451
+ item_id = raw_obj["Id"]
452
+ if not living_state.contains(item_id, with_deleted=False):
453
+ sys.stderr.write(
454
+ f"error: Entity '{item_id}' does not exist in Pit '{living_state.pit_name}'. "
455
+ "Use without --require-existing / --patch to allow creating new entities.\n"
456
+ )
457
+ return 1
458
+ except PitNotFoundError:
459
+ sys.stderr.write(
460
+ f"error: Entity '{items_to_add[0]['Id']}' does not exist in Pit '{args.pit}'. "
461
+ "Use without --require-existing / --patch to allow creating new entities.\n"
462
+ )
463
+ return 1
464
+ except Exception as ex:
465
+ sys.stderr.write(f"[jpit] Error: {ex}\n")
466
+ return 1
467
+
415
468
  with Pit.open(
416
469
  args.pit,
417
470
  cloud=args.cloud,
@@ -450,13 +503,12 @@ def cmd_set(args: argparse.Namespace) -> int:
450
503
  retain_window=getattr(args, "retain_window", False),
451
504
  ) as pit:
452
505
  existing = pit.get(args.id)
453
- if existing:
506
+ if existing is not None:
454
507
  existing.set_property(payload)
455
- pit.add(existing)
456
508
  else:
457
- payload["Id"] = args.id
458
- new_item = PitItem(payload)
459
- pit.add(new_item)
509
+ delta = dict(payload)
510
+ delta["Id"] = args.id
511
+ pit.add(PitItem(delta))
460
512
 
461
513
  print(f"[jpit] Updated entity '{args.id}' in Pit '{args.pit}'.")
462
514
  return 0
@@ -499,7 +551,6 @@ def cmd_delete_prop(args: argparse.Namespace) -> int:
499
551
  sys.stderr.write(f"[jpit] Entity '{args.id}' not found.\n")
500
552
  return 1
501
553
  item.delete_property_path(args.property_path)
502
- pit.add(item)
503
554
 
504
555
  print(f"[jpit] Tombstoned property '{args.property_path}' on entity '{args.id}'.")
505
556
  return 0
@@ -735,7 +786,10 @@ def print_top_help(
735
786
  # Dynamic Pits Discovery line
736
787
  pits_title = color(f"{Icons.INFO} PitNames", C_CYAN, use_color)
737
788
  if root:
738
- discovered = discover_root_pits(root, cloud)
789
+ try:
790
+ discovered = discover_root_pits(root, cloud)
791
+ except Exception:
792
+ discovered = []
739
793
  if discovered:
740
794
  formatted_pits = []
741
795
  for idx, p in enumerate(discovered):
@@ -827,7 +881,7 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
827
881
  ],
828
882
  },
829
883
  "put": {
830
- "usage": "jpit put <PitName> [<source>] [-s <source>] [options]",
884
+ "usage": "jpit put <PitName> [<source>] [-s <source>] [--require-existing] [--patch] [options]",
831
885
  "desc": "Ingest JSON5 entities from file or stdin pipe (alias: seed).",
832
886
  "args": [
833
887
  ("PitName", "Pit name or file path"),
@@ -835,6 +889,8 @@ def print_command_help(cmd: str, nologo: bool = False, use_color: bool = True) -
835
889
  ],
836
890
  "opts": [
837
891
  ("-s, --source <file>", "Source file for import (JSON or JSON5)"),
892
+ ("--require-existing", "Reject batch if any entity does not already exist"),
893
+ ("--patch", "Alias for --require-existing"),
838
894
  ],
839
895
  "aliases": ["seed"],
840
896
  },
@@ -999,7 +1055,22 @@ class JsonPitArgumentParser(argparse.ArgumentParser):
999
1055
  sys.exit(2)
1000
1056
 
1001
1057
 
1002
- def build_parser() -> JsonPitArgumentParser:
1058
+ def get_cli_prog_name(argv0: str | None = None) -> str:
1059
+ """Determines whether CLI is invoked as 'jsonpit' or 'jpit'."""
1060
+ if argv0 is None:
1061
+ argv0 = sys.argv[0] if sys.argv and sys.argv[0] else "jpit"
1062
+ base = Path(argv0).name.lower()
1063
+ if base == "jsonpit" or base.startswith("jsonpit"):
1064
+ return "jsonpit"
1065
+ if "jsonpit" in str(argv0).lower() and "jpit" not in base:
1066
+ return "jsonpit"
1067
+ return "jpit"
1068
+
1069
+
1070
+ def build_parser(prog: str | None = None) -> JsonPitArgumentParser:
1071
+ if prog is None:
1072
+ prog = get_cli_prog_name()
1073
+
1003
1074
  common_parser = JsonPitArgumentParser(add_help=False)
1004
1075
  common_parser.add_argument(
1005
1076
  "-c", "--cloud",
@@ -1025,15 +1096,15 @@ def build_parser() -> JsonPitArgumentParser:
1025
1096
  )
1026
1097
 
1027
1098
  parser = JsonPitArgumentParser(
1028
- prog="jpit",
1029
- description="jpit — Cloud-first distributed replicated storage CLI and Pit-Grep.",
1099
+ prog=prog,
1100
+ description=f"{prog} — Cloud-first distributed replicated storage CLI and Pit-Grep.",
1030
1101
  parents=[common_parser],
1031
1102
  )
1032
1103
  parser.add_argument(
1033
1104
  "-v",
1034
1105
  "--version",
1035
1106
  action="version",
1036
- version=f"%(prog)s {__version__}",
1107
+ version=f"%(prog)s v{__version__}",
1037
1108
  )
1038
1109
 
1039
1110
  subparsers = parser.add_subparsers(dest="command", required=True, parser_class=JsonPitArgumentParser)
@@ -1084,6 +1155,18 @@ def build_parser() -> JsonPitArgumentParser:
1084
1155
  p_put.add_argument("pit", help="Pit name")
1085
1156
  p_put.add_argument("source", nargs="?", default=None, help="Source file or '-' for stdin")
1086
1157
  p_put.add_argument("-s", "--source", dest="source_opt", help="Source file for import (JSON or JSON5)")
1158
+ p_put.add_argument(
1159
+ "--require-existing",
1160
+ action="store_true",
1161
+ default=False,
1162
+ help="CR047: Strictly require all incoming entity IDs to exist in the Pit",
1163
+ )
1164
+ p_put.add_argument(
1165
+ "--patch",
1166
+ action="store_true",
1167
+ default=False,
1168
+ help="CR047: Alias for --require-existing",
1169
+ )
1087
1170
 
1088
1171
  # set
1089
1172
  p_set = subparsers.add_parser("set", parents=[common_parser], help="Set or patch entity with JSON5 payload")
@@ -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())
@@ -29,3 +29,7 @@ class TombstoneError(JsonPitError):
29
29
 
30
30
  class ProtectedAttributeError(JsonPitError, ValueError):
31
31
  """Raised when attempting to manually mutate or inject protected lifecycle attributes (Modified, Deleted, Id)."""
32
+
33
+
34
+ class StrictPatchValidationError(JsonPitError, ValueError):
35
+ """CR047: Raised when --require-existing / --patch validation fails."""
@@ -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
@@ -11,6 +11,7 @@ import datetime
11
11
  import json
12
12
  from collections.abc import Iterator, MutableMapping
13
13
  from typing import Any
14
+ import weakref
14
15
 
15
16
  from .canonical import (
16
17
  canonical_json,
@@ -84,13 +85,17 @@ class PitItem(MutableMapping[str, Any]):
84
85
  deleted: bool = False,
85
86
  modified: datetime.datetime | None = None,
86
87
  invalidate: bool = True,
88
+ pit: Any | None = None,
87
89
  ) -> None:
88
90
  self._data: dict[str, Any] = {}
89
91
  self._dirty: bool = False
92
+ self._pit_ref: weakref.ref[Any] | None = weakref.ref(pit) if pit is not None else None
90
93
 
91
94
  if isinstance(initial, PitItem):
92
95
  self._data = copy.deepcopy(initial._data)
93
96
  self._dirty = initial._dirty
97
+ if pit is not None:
98
+ self._pit_ref = weakref.ref(pit)
94
99
  if id is not None:
95
100
  self._data["Id"] = id
96
101
  if note:
@@ -153,6 +158,16 @@ class PitItem(MutableMapping[str, Any]):
153
158
  else:
154
159
  self.validate()
155
160
 
161
+ def bind(self, pit: Any) -> PitItem:
162
+ """Binds this living entity to its parent Pit container."""
163
+ self._pit_ref = weakref.ref(pit) if pit is not None else None
164
+ return self
165
+
166
+ @property
167
+ def pit(self) -> Any | None:
168
+ """Returns the parent Pit if bound and alive, else None."""
169
+ return self._pit_ref() if self._pit_ref is not None else None
170
+
156
171
  # --- Property Accessors ---
157
172
 
158
173
  @property
@@ -264,15 +279,22 @@ class PitItem(MutableMapping[str, Any]):
264
279
  """
265
280
  Appends a property tombstone by setting the top-level property to None (JSON null).
266
281
  Resets Deleted to False and marks the entity dirty.
282
+ If bound to a Pit, automatically dispatches a sparse tombstone delta.
267
283
  """
268
284
  self.deleted = False
269
285
  self.invalidate()
270
286
  self._data[property_name] = None
287
+ p = self.pit
288
+ if p is not None:
289
+ mutation = PitItem(id=self.id)
290
+ mutation.delete_property(property_name)
291
+ p.add(mutation)
271
292
 
272
293
  def delete_property_path(self, property_path: str) -> None:
273
294
  """
274
295
  Appends a property tombstone at a dot-delimited nested JSON path (e.g. 'Address.City').
275
296
  Sets the leaf property to None (JSON null), resets Deleted to False, and marks dirty.
297
+ If bound to a Pit, automatically dispatches a sparse tombstone delta.
276
298
  """
277
299
  if not property_path or not property_path.strip():
278
300
  raise TombstoneError("A property path is required.")
@@ -304,11 +326,17 @@ class PitItem(MutableMapping[str, Any]):
304
326
  container[segments[-1]] = None
305
327
  self.deleted = False
306
328
  self.invalidate()
329
+ p = self.pit
330
+ if p is not None:
331
+ mutation = PitItem(id=self.id)
332
+ mutation.delete_property_path(property_path)
333
+ p.add(mutation)
307
334
 
308
335
  def delete(self, by: str | None = None, backdate_100: bool = True) -> bool:
309
336
  """
310
337
  Tombstones this item. Appends an audit trail to Note.
311
338
  If backdate_100 is True, preserves precedence by backdating Modified by 100 seconds.
339
+ If bound to a Pit, dispatches the tombstone to the Pit.
312
340
  Returns True if newly deleted, False if already deleted.
313
341
  """
314
342
  if self.deleted:
@@ -325,14 +353,13 @@ class PitItem(MutableMapping[str, Any]):
325
353
  if by:
326
354
  audit_entry += f" by {by}"
327
355
  self.note = f"{audit_entry};\n{self.note}" if self.note else f"{audit_entry};"
356
+ p = self.pit
357
+ if p is not None:
358
+ p.delete_item(self.id, by=by, backdate_100=backdate_100)
328
359
  return True
329
360
 
330
- def extend_with(self, obj: dict[str, Any]) -> bool:
331
- """
332
- Deep merges a dictionary into this entity.
333
- Arrays are replaced; objects are recursively merged; nulls (tombstones) are preserved.
334
- Returns True if any value was modified.
335
- """
361
+ def _apply_extend(self, obj: dict[str, Any]) -> bool:
362
+ """In-memory deep merge without dispatching to parent Pit."""
336
363
  original_canonical = canonical_json(self.to_dict())
337
364
 
338
365
  def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
@@ -349,6 +376,21 @@ class PitItem(MutableMapping[str, Any]):
349
376
  self.invalidate()
350
377
  return changed
351
378
 
379
+ def extend_with(self, obj: dict[str, Any]) -> bool:
380
+ """
381
+ Deep merges a dictionary into this entity.
382
+ Arrays are replaced; objects are recursively merged; nulls (tombstones) are preserved.
383
+ If bound to a Pit, automatically dispatches a sparse delta fragment.
384
+ Returns True if any value was modified.
385
+ """
386
+ changed = self._apply_extend(obj)
387
+ p = self.pit
388
+ if changed and p is not None:
389
+ delta = copy.deepcopy(obj)
390
+ delta["Id"] = self.id
391
+ p.add(PitItem(delta))
392
+ return changed
393
+
352
394
  def extend(self, json_string: str) -> bool:
353
395
  """Parses JSON text and merges it into this entity."""
354
396
  parsed = json.loads(json_string)
@@ -418,10 +460,19 @@ class PitItem(MutableMapping[str, Any]):
418
460
  else:
419
461
  self._data[key] = value
420
462
  self.invalidate()
463
+ p = self.pit
464
+ if p is not None:
465
+ p.add(PitItem({"Id": self.id, key: value}))
421
466
 
422
467
  def __delitem__(self, key: str) -> None:
423
- del self._data[key]
468
+ if key in self._data:
469
+ del self._data[key]
424
470
  self.invalidate()
471
+ p = self.pit
472
+ if p is not None:
473
+ mutation = PitItem(id=self.id)
474
+ mutation.delete_property(key)
475
+ p.add(mutation)
425
476
 
426
477
  def __iter__(self) -> Iterator[str]:
427
478
  # Ensure Id, Modified, Deleted are always visible in key enumeration
@@ -29,6 +29,7 @@ from .exceptions import (
29
29
  PitInstanceConflictError,
30
30
  PitNotFoundError,
31
31
  ProtectedAttributeError,
32
+ StrictPatchValidationError,
32
33
  )
33
34
  from .flags import (
34
35
  MasterFlagFile,
@@ -417,11 +418,12 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
417
418
  raise
418
419
  raise PitCorruptError(f"Failed to parse pit file {self.canonical_file}: {ex}") from ex
419
420
 
420
- def seed_from_file(self, file_path: Path | str) -> int:
421
+ def seed_from_file(self, file_path: Path | str, require_existing: bool = False) -> int:
421
422
  """
422
423
  Ingests a seed file (JSON or JSON5) and adds all entities to the pit.
423
424
  Returns count of imported entities.
424
425
  CR043: Validates payload shape and entities strictly before mutation.
426
+ CR047: If require_existing is True, validates that all IDs exist in living state.
425
427
  """
426
428
  path = Path(file_path)
427
429
  if not path.is_file():
@@ -430,6 +432,17 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
430
432
  text = path.read_text(encoding="utf-8")
431
433
  items_to_add = parse_and_validate_seed_payload(text, str(path))
432
434
 
435
+ if require_existing:
436
+ if not items_to_add:
437
+ raise StrictPatchValidationError("Patch source contained 0 entities.")
438
+ for raw_obj in items_to_add:
439
+ item_id = raw_obj["Id"]
440
+ if not self.contains(item_id, with_deleted=False):
441
+ raise StrictPatchValidationError(
442
+ f"Entity '{item_id}' does not exist in Pit '{self.pit_name}'. "
443
+ "Use without --require-existing / --patch to allow creating new entities."
444
+ )
445
+
433
446
  count = 0
434
447
  for raw_obj in items_to_add:
435
448
  item = PitItem(raw_obj)
@@ -647,6 +660,8 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
647
660
  if pit_items is None:
648
661
  return default
649
662
  projected = pit_items.project_state(at=at, with_deleted=with_deleted)
663
+ if projected is not None and at is None and not self.read_only:
664
+ projected.bind(self)
650
665
  return projected if projected is not None else default
651
666
 
652
667
  def get_at(
@@ -658,6 +673,12 @@ class Pit(JsonPitBase, MutableMapping[str, PitItem]):
658
673
  """Point-in-time historical projection of an entity."""
659
674
  return self.get(item_id, at=at, with_deleted=with_deleted)
660
675
 
676
+ def contains(self, item_id: str, with_deleted: bool = False) -> bool:
677
+ """100% C# parity: checks if an entity exists in living state."""
678
+ if not item_id:
679
+ return False
680
+ return self.get(item_id, with_deleted=with_deleted) is not None
681
+
661
682
  def all_undeleted(self) -> list[PitItem]:
662
683
  """Returns all active, non-deleted entities in this pit."""
663
684
  results: list[PitItem] = []
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jsonpit"
7
- version = "4.4.3"
7
+ version = "4.4.5"
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"
@@ -13,7 +13,7 @@ authors = [
13
13
  { name = "Dr. Rainer Burkhardt" },
14
14
  ]
15
15
  classifiers = [
16
- "Development Status :: 4 - Beta",
16
+ "Development Status :: 5 - Production/Stable",
17
17
  "Intended Audience :: Developers",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes