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.
- {jsonpit-4.4.2 → jsonpit-4.4.4}/PKG-INFO +67 -4
- {jsonpit-4.4.2 → jsonpit-4.4.4}/README.md +66 -3
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/__init__.py +7 -3
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/cli.py +86 -71
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/config.py +24 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/fs.py +6 -2
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/item.py +15 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/store.py +76 -21
- {jsonpit-4.4.2 → jsonpit-4.4.4}/pyproject.toml +1 -1
- {jsonpit-4.4.2 → jsonpit-4.4.4}/.gitignore +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/canonical.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/changes.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/exceptions.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/flags.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/jsonpit/history.py +0 -0
- {jsonpit-4.4.2 → jsonpit-4.4.4}/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.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.
|
|
34
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.4`)
|
|
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.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
|
|
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.
|
|
12
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.4`)
|
|
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.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
|
|
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 .
|
|
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.
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
387
|
-
path = Path(
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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
|
|
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
|
-
|
|
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.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
|