jsonpit 0.1.0__py3-none-any.whl
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/__init__.py +32 -0
- jsonpit/canonical.py +105 -0
- jsonpit/changes.py +186 -0
- jsonpit/cli.py +528 -0
- jsonpit/config.py +341 -0
- jsonpit/exceptions.py +27 -0
- jsonpit/flags.py +274 -0
- jsonpit/fs.py +142 -0
- jsonpit/history.py +203 -0
- jsonpit/item.py +426 -0
- jsonpit/store.py +645 -0
- jsonpit-0.1.0.dist-info/METADATA +153 -0
- jsonpit-0.1.0.dist-info/RECORD +15 -0
- jsonpit-0.1.0.dist-info/WHEEL +4 -0
- jsonpit-0.1.0.dist-info/entry_points.txt +3 -0
jsonpit/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jsonpit — Cloud-first, eventually-consistent replicated storage engine in pure Python.
|
|
3
|
+
100% C# JsonPit parity · Zero third-party runtime dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
JsonPitError,
|
|
8
|
+
PitConcurrencyError,
|
|
9
|
+
PitCorruptError,
|
|
10
|
+
PitInstanceConflictError,
|
|
11
|
+
PitNotFoundError,
|
|
12
|
+
TombstoneError,
|
|
13
|
+
)
|
|
14
|
+
from .fs import OsConfig
|
|
15
|
+
from .history import PitItems
|
|
16
|
+
from .item import PitItem
|
|
17
|
+
from .store import Pit
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Pit",
|
|
23
|
+
"PitItem",
|
|
24
|
+
"PitItems",
|
|
25
|
+
"OsConfig",
|
|
26
|
+
"JsonPitError",
|
|
27
|
+
"PitNotFoundError",
|
|
28
|
+
"PitCorruptError",
|
|
29
|
+
"PitConcurrencyError",
|
|
30
|
+
"PitInstanceConflictError",
|
|
31
|
+
"TombstoneError",
|
|
32
|
+
]
|
jsonpit/canonical.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic JSON canonicalization, content hashing, and time conversions.
|
|
3
|
+
Adheres strictly to the CR003 / v3.13.2 specification in OsLib.CanonicalJson.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# .NET ticks: 100-nanosecond intervals since 0001-01-01 00:00:00 UTC
|
|
14
|
+
TICKS_PER_MICROSECOND = 10
|
|
15
|
+
TICKS_PER_SECOND = 10_000_000
|
|
16
|
+
TICKS_AT_UNIX_EPOCH = 621_355_968_000_000_000
|
|
17
|
+
UNIX_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def utcnow() -> datetime.datetime:
|
|
21
|
+
"""Returns the current UTC datetime with explicit UTC timezone."""
|
|
22
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def datetime_to_utc_ticks(dt: datetime.datetime) -> int:
|
|
26
|
+
"""
|
|
27
|
+
Converts a UTC datetime to .NET DateTimeOffset.UtcTicks.
|
|
28
|
+
1 tick = 100 nanoseconds since 0001-01-01 00:00:00 UTC.
|
|
29
|
+
"""
|
|
30
|
+
if dt.tzinfo is None:
|
|
31
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
32
|
+
else:
|
|
33
|
+
dt = dt.astimezone(datetime.timezone.utc)
|
|
34
|
+
diff = dt - UNIX_EPOCH
|
|
35
|
+
total_microseconds = diff.days * 86_400_000_000 + diff.seconds * 1_000_000 + diff.microseconds
|
|
36
|
+
return TICKS_AT_UNIX_EPOCH + (total_microseconds * TICKS_PER_MICROSECOND)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def utc_ticks_to_datetime(ticks: int) -> datetime.datetime:
|
|
40
|
+
"""Converts .NET UtcTicks back to a timezone-aware UTC datetime."""
|
|
41
|
+
diff_ticks = ticks - TICKS_AT_UNIX_EPOCH
|
|
42
|
+
diff_microseconds = diff_ticks // TICKS_PER_MICROSECOND
|
|
43
|
+
return UNIX_EPOCH + datetime.timedelta(microseconds=diff_microseconds)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_iso_timestamp(dt: datetime.datetime) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Formats a UTC datetime in standard round-trip ISO-8601 format:
|
|
49
|
+
'yyyy-MM-ddTHH:mm:ss.ffffffZ' (or 'yyyy-MM-ddTHH:mm:ssZ' if zero microseconds).
|
|
50
|
+
"""
|
|
51
|
+
if dt.tzinfo is None:
|
|
52
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
53
|
+
else:
|
|
54
|
+
dt = dt.astimezone(datetime.timezone.utc)
|
|
55
|
+
if dt.microsecond == 0:
|
|
56
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
57
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_iso_timestamp(ts: str) -> datetime.datetime:
|
|
61
|
+
"""
|
|
62
|
+
Parses an ISO-8601 timestamp string into a timezone-aware UTC datetime.
|
|
63
|
+
Supports 'Z', '+00:00', negative offsets, and fractional seconds.
|
|
64
|
+
"""
|
|
65
|
+
clean_ts = ts.strip()
|
|
66
|
+
if clean_ts.endswith("Z"):
|
|
67
|
+
clean_ts = clean_ts[:-1] + "+00:00"
|
|
68
|
+
dt = datetime.datetime.fromisoformat(clean_ts)
|
|
69
|
+
if dt.tzinfo is None:
|
|
70
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
71
|
+
return dt.astimezone(datetime.timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def canonical_json(obj: Any) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Produces deterministic, compact JSON text without insignificant whitespace,
|
|
77
|
+
with dictionary keys sorted in ordinal order, matching OsLib.CanonicalJson.
|
|
78
|
+
"""
|
|
79
|
+
return json.dumps(
|
|
80
|
+
obj,
|
|
81
|
+
sort_keys=True,
|
|
82
|
+
separators=(",", ":"),
|
|
83
|
+
ensure_ascii=False,
|
|
84
|
+
default=_json_default_serializer,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _json_default_serializer(obj: Any) -> Any:
|
|
89
|
+
"""Serializes datetime objects and domain items to canonical JSON primitives."""
|
|
90
|
+
if isinstance(obj, datetime.datetime):
|
|
91
|
+
return format_iso_timestamp(obj)
|
|
92
|
+
if hasattr(obj, "to_dict"):
|
|
93
|
+
return obj.to_dict()
|
|
94
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sha256_hex(text: str) -> str:
|
|
98
|
+
"""Returns the full lowercase hex SHA-256 digest of the UTF-8 encoding of text."""
|
|
99
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def canonical_with_hash(obj: Any) -> tuple[str, str]:
|
|
103
|
+
"""Returns (canonical_json_string, sha256_hex_digest)."""
|
|
104
|
+
canonical = canonical_json(obj)
|
|
105
|
+
return canonical, sha256_hex(canonical)
|
jsonpit/changes.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collision-safe change files and receipt lifecycle protocol.
|
|
3
|
+
Adheres strictly to CR003 / CR021:
|
|
4
|
+
- Filename: {Modified.UtcTicks}_{ExactProcessIdentity}_{Sha256}.json
|
|
5
|
+
- Receipt: {Stem}.receipt (10-minute cleanup grace)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .canonical import (
|
|
16
|
+
canonical_json,
|
|
17
|
+
canonical_with_hash,
|
|
18
|
+
datetime_to_utc_ticks,
|
|
19
|
+
format_iso_timestamp,
|
|
20
|
+
parse_iso_timestamp,
|
|
21
|
+
sha256_hex,
|
|
22
|
+
utc_ticks_to_datetime,
|
|
23
|
+
utcnow,
|
|
24
|
+
)
|
|
25
|
+
from .fs import safe_delete_file, safe_read_text, safe_write_in_place
|
|
26
|
+
from .item import PitItem
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChangeFile:
|
|
30
|
+
"""
|
|
31
|
+
Collision-safe change-file identity and validated payload access.
|
|
32
|
+
Payload is [[fragment]] serialized as canonical UTF-8 JSON.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def canonical_payload_for(fragment: PitItem) -> tuple[str, str]:
|
|
37
|
+
"""Produces the canonical JSON payload [[fragment]] and its lowercase SHA-256."""
|
|
38
|
+
payload = [[fragment.to_dict()]]
|
|
39
|
+
return canonical_with_hash(payload)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def compose_name(
|
|
43
|
+
modified: datetime.datetime,
|
|
44
|
+
exact_process_identity: str,
|
|
45
|
+
sha256: str,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Composes the change-file stem: {UtcTicks}_{ExactProcessIdentity}_{Sha256}."""
|
|
48
|
+
ticks = datetime_to_utc_ticks(modified)
|
|
49
|
+
return f"{ticks}_{exact_process_identity}_{sha256}"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def compose_name_for(cls, fragment: PitItem, exact_process_identity: str) -> str:
|
|
53
|
+
"""Composes the change-file stem directly from a PitItem fragment."""
|
|
54
|
+
_, sha = cls.canonical_payload_for(fragment)
|
|
55
|
+
return cls.compose_name(fragment.modified, exact_process_identity, sha)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def try_parse_name(name_without_extension: str) -> tuple[int, str, str] | None:
|
|
59
|
+
"""
|
|
60
|
+
Parses a change-file name without extension.
|
|
61
|
+
Returns (utc_ticks, exact_process_identity, sha256) or None if invalid.
|
|
62
|
+
"""
|
|
63
|
+
if not name_without_extension:
|
|
64
|
+
return None
|
|
65
|
+
first_sep = name_without_extension.find("_")
|
|
66
|
+
last_sep = name_without_extension.rfind("_")
|
|
67
|
+
if first_sep <= 0 or last_sep <= first_sep:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
ticks_str = name_without_extension[:first_sep]
|
|
71
|
+
try:
|
|
72
|
+
ticks = int(ticks_str)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
sha = name_without_extension[last_sep + 1 :]
|
|
77
|
+
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha.lower()):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
identity = name_without_extension[first_sep + 1 : last_sep]
|
|
81
|
+
if not identity:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
return ticks, identity, sha.lower()
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def identity_of(cls, name_without_extension: str) -> str | None:
|
|
88
|
+
"""Extracts the process identity segment from a change file name."""
|
|
89
|
+
parsed = cls.try_parse_name(name_without_extension)
|
|
90
|
+
if parsed:
|
|
91
|
+
return parsed[1]
|
|
92
|
+
# Legacy format fallback: {ticks}_{identity}
|
|
93
|
+
sep = name_without_extension.find("_")
|
|
94
|
+
if sep > 0 and sep < len(name_without_extension) - 1:
|
|
95
|
+
try:
|
|
96
|
+
int(name_without_extension[:sep])
|
|
97
|
+
return name_without_extension[sep + 1 :]
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def read_validated(cls, file_path: Path) -> list[list[dict[str, Any]]] | None:
|
|
104
|
+
"""
|
|
105
|
+
Reads and validates a change file.
|
|
106
|
+
Verifies the file content matches the embedded SHA-256 hash.
|
|
107
|
+
Returns parsed JSON payload or None if invalid or corrupt.
|
|
108
|
+
"""
|
|
109
|
+
content = safe_read_text(file_path)
|
|
110
|
+
if content is None or not content.strip():
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
parsed_meta = cls.try_parse_name(file_path.stem)
|
|
114
|
+
if parsed_meta:
|
|
115
|
+
_, _, expected_sha = parsed_meta
|
|
116
|
+
actual_sha = sha256_hex(content)
|
|
117
|
+
if actual_sha != expected_sha:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(content)
|
|
122
|
+
if isinstance(data, list):
|
|
123
|
+
return data
|
|
124
|
+
return None
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def create(
|
|
130
|
+
cls,
|
|
131
|
+
pit_dir: Path,
|
|
132
|
+
fragment: PitItem,
|
|
133
|
+
exact_process_identity: str,
|
|
134
|
+
) -> Path:
|
|
135
|
+
"""
|
|
136
|
+
Writes a fragment as an ordinary collision-safe change file in pit_dir.
|
|
137
|
+
Filename: {Modified.UtcTicks}_{ExactProcessIdentity}_{Sha256}.json.
|
|
138
|
+
Exact byte contract: canonical UTF-8 JSON without trailing newline.
|
|
139
|
+
"""
|
|
140
|
+
payload, sha = cls.canonical_payload_for(fragment)
|
|
141
|
+
stem = cls.compose_name(fragment.modified, exact_process_identity, sha)
|
|
142
|
+
target_path = pit_dir / f"{stem}.json"
|
|
143
|
+
|
|
144
|
+
if not target_path.is_file():
|
|
145
|
+
safe_write_in_place(target_path, payload)
|
|
146
|
+
|
|
147
|
+
return target_path
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ReceiptFile:
|
|
151
|
+
"""
|
|
152
|
+
Immutable cleanup-eligibility receipt for one JsonPit change file (CR021).
|
|
153
|
+
Stored beside the change file with the same stem and the .receipt extension.
|
|
154
|
+
Content is the single round-trip ISO-8601 UTC timestamp of canonical accounting.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
CLEANUP_GRACE: datetime.timedelta = datetime.timedelta(minutes=10)
|
|
158
|
+
|
|
159
|
+
def __init__(self, change_file_path: Path) -> None:
|
|
160
|
+
self.change_file_path = change_file_path
|
|
161
|
+
self.path = change_file_path.with_suffix(".receipt")
|
|
162
|
+
|
|
163
|
+
if self.path.is_file():
|
|
164
|
+
content = safe_read_text(self.path) or ""
|
|
165
|
+
line = content.splitlines()[0] if content.splitlines() else ""
|
|
166
|
+
try:
|
|
167
|
+
self.time = parse_iso_timestamp(line)
|
|
168
|
+
except Exception:
|
|
169
|
+
self.time = utcnow()
|
|
170
|
+
else:
|
|
171
|
+
self.time = utcnow()
|
|
172
|
+
safe_write_in_place(self.path, format_iso_timestamp(self.time))
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_eligible_for_cleanup(self) -> bool:
|
|
176
|
+
"""True when the 10-minute grace period has passed since canonical accounting."""
|
|
177
|
+
return (utcnow() - self.time) >= self.CLEANUP_GRACE
|
|
178
|
+
|
|
179
|
+
def remove(self) -> bool:
|
|
180
|
+
"""Removes this receipt file from disk."""
|
|
181
|
+
return safe_delete_file(self.path)
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def path_for(cls, change_file_path: Path) -> Path:
|
|
185
|
+
"""Returns the path of the sibling receipt file."""
|
|
186
|
+
return change_file_path.with_suffix(".receipt")
|