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 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")