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.
- {jsonpit-4.4.3 → jsonpit-4.4.5}/PKG-INFO +68 -5
- {jsonpit-4.4.3 → jsonpit-4.4.5}/README.md +66 -3
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/__init__.py +6 -6
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/cli.py +124 -41
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/config.py +24 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/exceptions.py +4 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/fs.py +6 -2
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/item.py +58 -7
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/store.py +22 -1
- {jsonpit-4.4.3 → jsonpit-4.4.5}/pyproject.toml +2 -2
- {jsonpit-4.4.3 → jsonpit-4.4.5}/.gitignore +0 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/canonical.py +0 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/changes.py +0 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/flags.py +0 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/history.py +0 -0
- {jsonpit-4.4.3 → jsonpit-4.4.5}/jsonpit/icons.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jsonpit
|
|
3
|
-
Version: 4.4.
|
|
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 ::
|
|
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.
|
|
34
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.5`)
|
|
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.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
|
|
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.
|
|
12
|
+
## Cross-Tool & Cross-Language Version Alignment (`v4.4.5`)
|
|
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.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
|
|
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 .
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
pit.add(
|
|
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
|
-
|
|
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
|
|
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=
|
|
1029
|
-
description="
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 ::
|
|
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
|