pi-durable-python 1.3.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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Mutable document drafts backed by Chord-compatible operation batches.
|
|
2
|
+
|
|
3
|
+
A draft is a transaction-scoped overlay over one committed document value. Reads return
|
|
4
|
+
transaction-scoped views; writes copy their placed value into the draft immediately and record
|
|
5
|
+
a replayable Chord operation batch that reproduces the final value. Unchanged subtrees keep
|
|
6
|
+
sharing the committed revision, so an empty batch publishes and writes nothing while a structural
|
|
7
|
+
no-op such as ``shift()`` followed by ``unshift(first)`` is still a durable change.
|
|
8
|
+
|
|
9
|
+
Drafts revoke when the transaction settles. Mutations and property/index reads check that seal.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
from ..delta import MAX_DELTA_OPERATIONS, Op, overlap_strings
|
|
19
|
+
from ..json import JsonObject, JsonValue, copy_json
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
_MISSING = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── JSON equality and operation emission ────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_container(value: object) -> bool:
|
|
30
|
+
return isinstance(value, (dict, list))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _same_value(left: object, right: object) -> bool:
|
|
34
|
+
"""JSON equality with JavaScript's strict scalar identity."""
|
|
35
|
+
|
|
36
|
+
if left is right:
|
|
37
|
+
return True
|
|
38
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
39
|
+
return type(left) is type(right) and left is right
|
|
40
|
+
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
|
|
41
|
+
return left == right
|
|
42
|
+
if isinstance(left, dict) and isinstance(right, dict):
|
|
43
|
+
if left.keys() != right.keys():
|
|
44
|
+
return False
|
|
45
|
+
return all(_same_value(left[key], right[key]) for key in left)
|
|
46
|
+
if isinstance(left, list) and isinstance(right, list):
|
|
47
|
+
return len(left) == len(right) and all(
|
|
48
|
+
_same_value(a, b) for a, b in zip(left, right, strict=True)
|
|
49
|
+
)
|
|
50
|
+
return type(left) is type(right) and left == right
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _overlap(before: str, after: str) -> int:
|
|
54
|
+
"""Longest suffix of ``before`` that is a prefix of ``after``, within Chord's scan bound."""
|
|
55
|
+
|
|
56
|
+
return overlap_strings(before, after)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _emit_changed(ops: list[Op], path: list[Any], before: object, after: JsonValue) -> None:
|
|
60
|
+
"""Emit one operation that turns ``before`` into ``after`` at ``path``."""
|
|
61
|
+
|
|
62
|
+
if before is not _MISSING and _same_value(before, after):
|
|
63
|
+
return
|
|
64
|
+
if isinstance(before, str) and isinstance(after, str):
|
|
65
|
+
if after.startswith(before):
|
|
66
|
+
ops.append(["a", path, after[len(before) :]])
|
|
67
|
+
return
|
|
68
|
+
shared = _overlap(before, after)
|
|
69
|
+
if shared > 0:
|
|
70
|
+
ops.append(["t", path, len(before) - shared])
|
|
71
|
+
if len(after) > shared:
|
|
72
|
+
ops.append(["a", path, after[shared:]])
|
|
73
|
+
return
|
|
74
|
+
ops.append(["s", path, after])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Overlay nodes ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class _Slot:
|
|
82
|
+
"""One array position: a retained base element or a detached insertion."""
|
|
83
|
+
|
|
84
|
+
base_index: int | None
|
|
85
|
+
value: JsonValue
|
|
86
|
+
inserted: bool = False
|
|
87
|
+
override: bool = False
|
|
88
|
+
node: _ObjectNode | _ArrayNode | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _ObjectNode:
|
|
92
|
+
"""Overlay over one JSON object."""
|
|
93
|
+
|
|
94
|
+
__slots__ = (
|
|
95
|
+
"_materialized",
|
|
96
|
+
"base",
|
|
97
|
+
"children",
|
|
98
|
+
"deletes",
|
|
99
|
+
"draft",
|
|
100
|
+
"readded",
|
|
101
|
+
"writes",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
base: dict[str, Any]
|
|
105
|
+
children: dict[str, _ObjectNode | _ArrayNode]
|
|
106
|
+
deletes: dict[str, None]
|
|
107
|
+
draft: DocumentDraft
|
|
108
|
+
readded: dict[str, None]
|
|
109
|
+
writes: dict[str, Any]
|
|
110
|
+
_materialized: Any
|
|
111
|
+
|
|
112
|
+
def __init__(self, draft: DocumentDraft, base: dict[str, Any]) -> None:
|
|
113
|
+
self.draft = draft
|
|
114
|
+
self.base = base
|
|
115
|
+
self.writes = {}
|
|
116
|
+
self.deletes = {}
|
|
117
|
+
self.readded = {}
|
|
118
|
+
self.children = {}
|
|
119
|
+
self._materialized = _MISSING
|
|
120
|
+
|
|
121
|
+
# Reads
|
|
122
|
+
|
|
123
|
+
def has(self, key: str) -> bool:
|
|
124
|
+
if key in self.deletes:
|
|
125
|
+
return False
|
|
126
|
+
return key in self.writes or key in self.base
|
|
127
|
+
|
|
128
|
+
def get(self, key: str) -> object:
|
|
129
|
+
if key in self.deletes:
|
|
130
|
+
return _MISSING
|
|
131
|
+
if key in self.writes:
|
|
132
|
+
return self.writes[key]
|
|
133
|
+
return self.base.get(key, _MISSING)
|
|
134
|
+
|
|
135
|
+
def keys(self) -> list[str]:
|
|
136
|
+
keys = [key for key in self.base if key not in self.deletes and key not in self.readded]
|
|
137
|
+
for key in self.writes:
|
|
138
|
+
if key not in keys:
|
|
139
|
+
keys.append(key)
|
|
140
|
+
return keys
|
|
141
|
+
|
|
142
|
+
def wrap(self, key: str) -> Any:
|
|
143
|
+
value = self.get(key)
|
|
144
|
+
if not _is_container(value):
|
|
145
|
+
return value
|
|
146
|
+
child = self.children.get(key)
|
|
147
|
+
if child is None or child.base is not value:
|
|
148
|
+
child = self.draft._node_for(value)
|
|
149
|
+
self.children[key] = child
|
|
150
|
+
return self.draft._view(child)
|
|
151
|
+
|
|
152
|
+
# Writes
|
|
153
|
+
|
|
154
|
+
def set(self, key: str, value: Any) -> None:
|
|
155
|
+
placed = self.draft._place(value)
|
|
156
|
+
deleted = key in self.deletes
|
|
157
|
+
current = self.get(key)
|
|
158
|
+
if not deleted and current is not _MISSING:
|
|
159
|
+
if not _is_container(current) and not _is_container(placed) and _same_value(current, placed):
|
|
160
|
+
return
|
|
161
|
+
if deleted:
|
|
162
|
+
self.deletes.pop(key, None)
|
|
163
|
+
if key in self.base:
|
|
164
|
+
self.readded[key] = None
|
|
165
|
+
self.writes[key] = placed
|
|
166
|
+
self.children.pop(key, None)
|
|
167
|
+
self._materialized = _MISSING
|
|
168
|
+
|
|
169
|
+
def delete(self, key: str) -> None:
|
|
170
|
+
if not self.has(key):
|
|
171
|
+
return
|
|
172
|
+
self.writes.pop(key, None)
|
|
173
|
+
self.children.pop(key, None)
|
|
174
|
+
self.readded.pop(key, None)
|
|
175
|
+
if key in self.base:
|
|
176
|
+
self.deletes[key] = None
|
|
177
|
+
self._materialized = _MISSING
|
|
178
|
+
|
|
179
|
+
# Materialization and emission
|
|
180
|
+
|
|
181
|
+
def snapshot(self) -> dict[str, Any]:
|
|
182
|
+
result: dict[str, Any] = {}
|
|
183
|
+
for key in self.keys():
|
|
184
|
+
if key in self.writes:
|
|
185
|
+
value = self.writes[key]
|
|
186
|
+
child = self.children.get(key)
|
|
187
|
+
if child is not None:
|
|
188
|
+
value = child.snapshot()
|
|
189
|
+
else:
|
|
190
|
+
value = self.base[key]
|
|
191
|
+
child = self.children.get(key)
|
|
192
|
+
if child is not None:
|
|
193
|
+
value = child.snapshot()
|
|
194
|
+
result[key] = value
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
def materialize(self) -> dict[str, Any]:
|
|
198
|
+
if self._materialized is not _MISSING:
|
|
199
|
+
return cast(dict[str, Any], self._materialized)
|
|
200
|
+
|
|
201
|
+
changed = bool(self.writes or self.deletes)
|
|
202
|
+
result: dict[str, Any] = {}
|
|
203
|
+
for key in self.keys():
|
|
204
|
+
current = self.base.get(key)
|
|
205
|
+
child = self.children.get(key)
|
|
206
|
+
if key in self.writes:
|
|
207
|
+
value = self.writes[key]
|
|
208
|
+
else:
|
|
209
|
+
value = current
|
|
210
|
+
if child is not None:
|
|
211
|
+
value = child.materialize()
|
|
212
|
+
result[key] = value
|
|
213
|
+
if value is not current:
|
|
214
|
+
changed = True
|
|
215
|
+
if not changed and list(result) == list(self.base):
|
|
216
|
+
self._materialized = self.base
|
|
217
|
+
return self.base
|
|
218
|
+
self._materialized = result
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def emit(self, path: list[Any], ops: list[Op]) -> None:
|
|
222
|
+
for key in self.readded:
|
|
223
|
+
if key in self.base:
|
|
224
|
+
ops.append(["d", [*path, key]])
|
|
225
|
+
|
|
226
|
+
for key in self.writes:
|
|
227
|
+
before = _MISSING if key in self.readded else self.base.get(key, _MISSING)
|
|
228
|
+
after = self.writes[key]
|
|
229
|
+
child = self.children.get(key)
|
|
230
|
+
if child is not None:
|
|
231
|
+
after = child.materialize()
|
|
232
|
+
_emit_changed(ops, [*path, key], before, after)
|
|
233
|
+
|
|
234
|
+
for key in self.deletes:
|
|
235
|
+
if key in self.base and key not in self.writes:
|
|
236
|
+
ops.append(["d", [*path, key]])
|
|
237
|
+
|
|
238
|
+
for key, child in self.children.items():
|
|
239
|
+
if key in self.writes or key in self.deletes:
|
|
240
|
+
continue
|
|
241
|
+
child.emit([*path, key], ops)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _ArrayNode:
|
|
245
|
+
"""Overlay over one JSON array, retaining base positions and detached insertions."""
|
|
246
|
+
|
|
247
|
+
__slots__ = ("_materialized", "base", "draft", "reordered", "slots", "structural")
|
|
248
|
+
|
|
249
|
+
base: list[Any]
|
|
250
|
+
draft: DocumentDraft
|
|
251
|
+
reordered: bool
|
|
252
|
+
slots: list[_Slot]
|
|
253
|
+
structural: bool
|
|
254
|
+
_materialized: Any
|
|
255
|
+
|
|
256
|
+
def __init__(self, draft: DocumentDraft, base: list[Any]) -> None:
|
|
257
|
+
self.draft = draft
|
|
258
|
+
self.base = base
|
|
259
|
+
self.slots = [_Slot(index, value) for index, value in enumerate(base)]
|
|
260
|
+
self.structural = False
|
|
261
|
+
self.reordered = False
|
|
262
|
+
self._materialized = _MISSING
|
|
263
|
+
|
|
264
|
+
# Reads
|
|
265
|
+
|
|
266
|
+
def length(self) -> int:
|
|
267
|
+
return len(self.slots)
|
|
268
|
+
|
|
269
|
+
def value_at(self, index: int) -> Any:
|
|
270
|
+
slot = self.slots[index]
|
|
271
|
+
if slot.node is not None:
|
|
272
|
+
return slot.node.snapshot()
|
|
273
|
+
return slot.value
|
|
274
|
+
|
|
275
|
+
def wrap_at(self, index: int) -> Any:
|
|
276
|
+
slot = self.slots[index]
|
|
277
|
+
value = slot.value
|
|
278
|
+
if slot.node is None and _is_container(value):
|
|
279
|
+
slot.node = self.draft._node_for(value)
|
|
280
|
+
if slot.node is None:
|
|
281
|
+
return value
|
|
282
|
+
return self.draft._view(slot.node)
|
|
283
|
+
|
|
284
|
+
def values(self) -> list[Any]:
|
|
285
|
+
return [slot.value if slot.node is None else slot.node.snapshot() for slot in self.slots]
|
|
286
|
+
|
|
287
|
+
def snapshot(self) -> list[Any]:
|
|
288
|
+
return self.values()
|
|
289
|
+
|
|
290
|
+
# Structural edits
|
|
291
|
+
|
|
292
|
+
def _insert_slots(self, values: Iterable[Any]) -> list[_Slot]:
|
|
293
|
+
slots: list[_Slot] = []
|
|
294
|
+
for value in values:
|
|
295
|
+
slots.append(_Slot(None, self.draft._place(value), inserted=True))
|
|
296
|
+
return slots
|
|
297
|
+
|
|
298
|
+
def splice(self, index: int, remove: int, values: Iterable[Any]) -> list[Any]:
|
|
299
|
+
if index < 0:
|
|
300
|
+
index = max(0, len(self.slots) + index)
|
|
301
|
+
index = min(index, len(self.slots))
|
|
302
|
+
removed = [self.value_at(position) for position in range(index, min(index + remove, len(self.slots)))]
|
|
303
|
+
self.slots[index : index + remove] = self._insert_slots(values)
|
|
304
|
+
self.structural = True
|
|
305
|
+
self._materialized = _MISSING
|
|
306
|
+
return removed
|
|
307
|
+
|
|
308
|
+
def set(self, index: int, value: Any) -> None:
|
|
309
|
+
placed = self.draft._place(value)
|
|
310
|
+
slot = self.slots[index]
|
|
311
|
+
if not slot.inserted:
|
|
312
|
+
slot.override = True
|
|
313
|
+
slot.value = placed
|
|
314
|
+
slot.node = None
|
|
315
|
+
self._materialized = _MISSING
|
|
316
|
+
|
|
317
|
+
def delete(self, index: int) -> None:
|
|
318
|
+
self.splice(index, 1, ())
|
|
319
|
+
|
|
320
|
+
def reverse(self) -> None:
|
|
321
|
+
self.slots.reverse()
|
|
322
|
+
self.reordered = True
|
|
323
|
+
self._materialized = _MISSING
|
|
324
|
+
|
|
325
|
+
def sort(self, key: Any = None, reverse: bool = False) -> None:
|
|
326
|
+
if key is None:
|
|
327
|
+
self.slots.sort(key=lambda slot: self._sort_value(slot), reverse=reverse)
|
|
328
|
+
else:
|
|
329
|
+
self.slots.sort(key=lambda slot: key(self._sort_value(slot)), reverse=reverse)
|
|
330
|
+
self.reordered = True
|
|
331
|
+
self._materialized = _MISSING
|
|
332
|
+
|
|
333
|
+
def _sort_value(self, slot: _Slot) -> Any:
|
|
334
|
+
return slot.value if slot.node is None else slot.node.snapshot()
|
|
335
|
+
|
|
336
|
+
# Materialization and emission
|
|
337
|
+
|
|
338
|
+
def materialize(self) -> list[Any]:
|
|
339
|
+
if self._materialized is not _MISSING:
|
|
340
|
+
return cast(list[Any], self._materialized)
|
|
341
|
+
if not self.structural and not self.reordered:
|
|
342
|
+
unchanged = True
|
|
343
|
+
result: list[Any] = []
|
|
344
|
+
for position, slot in enumerate(self.slots):
|
|
345
|
+
value = slot.value
|
|
346
|
+
if slot.node is not None:
|
|
347
|
+
value = slot.node.materialize()
|
|
348
|
+
if slot.override or value is not self.base[position]:
|
|
349
|
+
unchanged = False
|
|
350
|
+
result.append(value)
|
|
351
|
+
if unchanged:
|
|
352
|
+
self._materialized = self.base
|
|
353
|
+
return self.base
|
|
354
|
+
self._materialized = result
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
result = []
|
|
358
|
+
for slot in self.slots:
|
|
359
|
+
if slot.node is not None:
|
|
360
|
+
result.append(slot.node.materialize())
|
|
361
|
+
else:
|
|
362
|
+
result.append(slot.value)
|
|
363
|
+
self._materialized = result
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
def emit(self, path: list[Any], ops: list[Op]) -> None:
|
|
367
|
+
if self.structural:
|
|
368
|
+
self._emit_structural(path, ops)
|
|
369
|
+
return
|
|
370
|
+
if self.reordered:
|
|
371
|
+
permutation = [cast(int, slot.base_index) for slot in self.slots]
|
|
372
|
+
if permutation != list(range(len(self.base))):
|
|
373
|
+
ops.append(["m", path, permutation])
|
|
374
|
+
self._emit_slot_changes(path, ops)
|
|
375
|
+
return
|
|
376
|
+
self._emit_slot_changes(path, ops)
|
|
377
|
+
|
|
378
|
+
def _emit_structural(self, path: list[Any], ops: list[Op]) -> None:
|
|
379
|
+
retained = [slot.base_index for slot in self.slots if not slot.inserted]
|
|
380
|
+
retained_set = set(retained)
|
|
381
|
+
|
|
382
|
+
removals: list[tuple[int, int]] = []
|
|
383
|
+
end = len(self.base)
|
|
384
|
+
while end > 0:
|
|
385
|
+
if end - 1 in retained_set:
|
|
386
|
+
end -= 1
|
|
387
|
+
continue
|
|
388
|
+
start = end - 1
|
|
389
|
+
while start > 0 and start - 1 not in retained_set:
|
|
390
|
+
start -= 1
|
|
391
|
+
removals.append((start, end - start))
|
|
392
|
+
end = start
|
|
393
|
+
for start, remove in removals:
|
|
394
|
+
ops.append(["p", path, start, remove, []])
|
|
395
|
+
|
|
396
|
+
retained_values = [cast(int, index) for index in retained]
|
|
397
|
+
if retained_values != sorted(retained_values):
|
|
398
|
+
positions = {value: index for index, value in enumerate(sorted(retained_values))}
|
|
399
|
+
ops.append(["m", path, [positions[value] for value in retained_values]])
|
|
400
|
+
|
|
401
|
+
index = 0
|
|
402
|
+
while index < len(self.slots):
|
|
403
|
+
if not self.slots[index].inserted:
|
|
404
|
+
index += 1
|
|
405
|
+
continue
|
|
406
|
+
start = index
|
|
407
|
+
items: list[Any] = []
|
|
408
|
+
while index < len(self.slots) and self.slots[index].inserted:
|
|
409
|
+
slot = self.slots[index]
|
|
410
|
+
items.append(slot.value if slot.node is None else slot.node.materialize())
|
|
411
|
+
index += 1
|
|
412
|
+
ops.append(["p", path, start, 0, items])
|
|
413
|
+
|
|
414
|
+
self._emit_slot_changes(path, ops)
|
|
415
|
+
|
|
416
|
+
def _emit_slot_changes(self, path: list[Any], ops: list[Op]) -> None:
|
|
417
|
+
for index, slot in enumerate(self.slots):
|
|
418
|
+
if slot.inserted:
|
|
419
|
+
continue
|
|
420
|
+
after: Any = slot.node.materialize() if slot.node is not None else slot.value
|
|
421
|
+
if slot.override:
|
|
422
|
+
base_index = cast(int, slot.base_index)
|
|
423
|
+
_emit_changed(ops, [*path, index], self.base[base_index], after)
|
|
424
|
+
elif slot.node is not None:
|
|
425
|
+
slot.node.emit([*path, index], ops)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True, slots=True)
|
|
429
|
+
class PreparedDraft:
|
|
430
|
+
"""A sealed draft: the immutable next revision and its detached op batch."""
|
|
431
|
+
|
|
432
|
+
value: JsonObject
|
|
433
|
+
ops: list[Op] = field(default_factory=list)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ── Public draft views ──────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class DraftObject:
|
|
440
|
+
"""A mutable object member of a document draft."""
|
|
441
|
+
|
|
442
|
+
__slots__ = ("_draft", "_node")
|
|
443
|
+
|
|
444
|
+
_draft: DocumentDraft
|
|
445
|
+
_node: Any
|
|
446
|
+
|
|
447
|
+
def __init__(self, draft: DocumentDraft, node: _ObjectNode) -> None:
|
|
448
|
+
object.__setattr__(self, "_draft", draft)
|
|
449
|
+
object.__setattr__(self, "_node", node)
|
|
450
|
+
|
|
451
|
+
def _check(self) -> None:
|
|
452
|
+
self._draft._check_open()
|
|
453
|
+
|
|
454
|
+
def __getattr__(self, name: str) -> Any:
|
|
455
|
+
self._check()
|
|
456
|
+
if name.startswith("_"):
|
|
457
|
+
raise AttributeError(name)
|
|
458
|
+
value = self._node.get(name)
|
|
459
|
+
if value is _MISSING:
|
|
460
|
+
raise AttributeError(name)
|
|
461
|
+
return self._node.wrap(name)
|
|
462
|
+
|
|
463
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
464
|
+
self._check()
|
|
465
|
+
if name in _INTERNAL:
|
|
466
|
+
object.__setattr__(self, name, value)
|
|
467
|
+
return
|
|
468
|
+
self._node.set(name, value)
|
|
469
|
+
|
|
470
|
+
def __delattr__(self, name: str) -> None:
|
|
471
|
+
self._check()
|
|
472
|
+
self._node.delete(name)
|
|
473
|
+
|
|
474
|
+
def __getitem__(self, key: str) -> Any:
|
|
475
|
+
self._check()
|
|
476
|
+
value = self._node.get(key)
|
|
477
|
+
if value is _MISSING:
|
|
478
|
+
raise KeyError(key)
|
|
479
|
+
return self._node.wrap(key)
|
|
480
|
+
|
|
481
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
482
|
+
self._check()
|
|
483
|
+
self._node.set(key, value)
|
|
484
|
+
|
|
485
|
+
def __delitem__(self, key: str) -> None:
|
|
486
|
+
self._check()
|
|
487
|
+
self._node.delete(key)
|
|
488
|
+
|
|
489
|
+
def __contains__(self, key: object) -> bool:
|
|
490
|
+
self._check()
|
|
491
|
+
return isinstance(key, str) and self._node.has(key)
|
|
492
|
+
|
|
493
|
+
def __iter__(self) -> Iterator[str]:
|
|
494
|
+
self._check()
|
|
495
|
+
return iter(self._node.keys())
|
|
496
|
+
|
|
497
|
+
def __len__(self) -> int:
|
|
498
|
+
self._check()
|
|
499
|
+
return len(self._node.keys())
|
|
500
|
+
|
|
501
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
502
|
+
self._check()
|
|
503
|
+
value = self._node.get(key)
|
|
504
|
+
if value is _MISSING:
|
|
505
|
+
return default
|
|
506
|
+
return self._node.wrap(key)
|
|
507
|
+
|
|
508
|
+
def keys(self) -> Iterable[str]:
|
|
509
|
+
self._check()
|
|
510
|
+
return self._node.keys()
|
|
511
|
+
|
|
512
|
+
def values(self) -> Iterable[Any]:
|
|
513
|
+
self._check()
|
|
514
|
+
return (self._node.wrap(key) for key in self._node.keys())
|
|
515
|
+
|
|
516
|
+
def items(self) -> Iterable[tuple[str, Any]]:
|
|
517
|
+
self._check()
|
|
518
|
+
return ((key, self._node.wrap(key)) for key in self._node.keys())
|
|
519
|
+
|
|
520
|
+
def pop(self, key: str, default: Any = None) -> Any:
|
|
521
|
+
self._check()
|
|
522
|
+
value = self._node.get(key)
|
|
523
|
+
if value is _MISSING:
|
|
524
|
+
return default
|
|
525
|
+
wrapped = self._node.wrap(key)
|
|
526
|
+
self._node.delete(key)
|
|
527
|
+
return wrapped
|
|
528
|
+
|
|
529
|
+
def update(self, values: Mapping[str, Any]) -> None:
|
|
530
|
+
self._check()
|
|
531
|
+
for key, value in values.items():
|
|
532
|
+
self._node.set(key, value)
|
|
533
|
+
|
|
534
|
+
def snapshot(self) -> dict[str, Any]:
|
|
535
|
+
"""A detached plain JSON object of this draft member."""
|
|
536
|
+
|
|
537
|
+
self._check()
|
|
538
|
+
return self._node.snapshot()
|
|
539
|
+
|
|
540
|
+
def __repr__(self) -> str:
|
|
541
|
+
return f"DraftObject({self._node.snapshot()!r})"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class DraftList:
|
|
545
|
+
"""A mutable array member of a document draft."""
|
|
546
|
+
|
|
547
|
+
__slots__ = ("_draft", "_node")
|
|
548
|
+
|
|
549
|
+
_draft: DocumentDraft
|
|
550
|
+
_node: Any
|
|
551
|
+
|
|
552
|
+
def __init__(self, draft: DocumentDraft, node: _ArrayNode) -> None:
|
|
553
|
+
object.__setattr__(self, "_draft", draft)
|
|
554
|
+
object.__setattr__(self, "_node", node)
|
|
555
|
+
|
|
556
|
+
def _check(self) -> None:
|
|
557
|
+
self._draft._check_open()
|
|
558
|
+
|
|
559
|
+
def __len__(self) -> int:
|
|
560
|
+
self._check()
|
|
561
|
+
return self._node.length()
|
|
562
|
+
|
|
563
|
+
def __iter__(self) -> Iterator[Any]:
|
|
564
|
+
self._check()
|
|
565
|
+
for index in range(self._node.length()):
|
|
566
|
+
yield self._node.wrap_at(index)
|
|
567
|
+
|
|
568
|
+
def __getitem__(self, index: int | slice) -> Any:
|
|
569
|
+
self._check()
|
|
570
|
+
if isinstance(index, slice):
|
|
571
|
+
return [self._node.wrap_at(i) for i in range(*index.indices(self._node.length()))]
|
|
572
|
+
return self._node.wrap_at(index)
|
|
573
|
+
|
|
574
|
+
def __setitem__(self, index: int | slice, value: Any) -> None:
|
|
575
|
+
self._check()
|
|
576
|
+
if isinstance(index, slice):
|
|
577
|
+
if not isinstance(value, Iterable):
|
|
578
|
+
raise TypeError("can only assign an iterable to a list slice")
|
|
579
|
+
start, stop, step = index.indices(self._node.length())
|
|
580
|
+
if step == 1:
|
|
581
|
+
self._node.splice(start, stop - start, value)
|
|
582
|
+
else:
|
|
583
|
+
targets = list(range(start, stop, step))
|
|
584
|
+
values = list(value)
|
|
585
|
+
if len(targets) != len(values):
|
|
586
|
+
raise ValueError(
|
|
587
|
+
f"attempt to assign sequence of size {len(values)} "
|
|
588
|
+
f"to extended slice of size {len(targets)}"
|
|
589
|
+
)
|
|
590
|
+
for target, item in zip(targets, values, strict=True):
|
|
591
|
+
self._node.set(target, item)
|
|
592
|
+
else:
|
|
593
|
+
self._node.set(index, value)
|
|
594
|
+
|
|
595
|
+
def __delitem__(self, index: int | slice) -> None:
|
|
596
|
+
self._check()
|
|
597
|
+
if isinstance(index, slice):
|
|
598
|
+
start, stop, step = index.indices(self._node.length())
|
|
599
|
+
if step == 1:
|
|
600
|
+
self._node.splice(start, stop - start, ())
|
|
601
|
+
else:
|
|
602
|
+
for target in reversed(list(range(start, stop, step))):
|
|
603
|
+
self._node.delete(target)
|
|
604
|
+
else:
|
|
605
|
+
self._node.delete(index)
|
|
606
|
+
|
|
607
|
+
def append(self, value: Any) -> None:
|
|
608
|
+
self._check()
|
|
609
|
+
self._node.splice(self._node.length(), 0, (value,))
|
|
610
|
+
|
|
611
|
+
def push(self, *values: Any) -> None:
|
|
612
|
+
self._check()
|
|
613
|
+
self._node.splice(self._node.length(), 0, values)
|
|
614
|
+
|
|
615
|
+
def extend(self, values: Iterable[Any]) -> None:
|
|
616
|
+
self._check()
|
|
617
|
+
self._node.splice(self._node.length(), 0, values)
|
|
618
|
+
|
|
619
|
+
def insert(self, index: int, value: Any) -> None:
|
|
620
|
+
self._check()
|
|
621
|
+
self._node.splice(index, 0, (value,))
|
|
622
|
+
|
|
623
|
+
def pop(self, index: int = -1) -> Any:
|
|
624
|
+
self._check()
|
|
625
|
+
return self._node.splice(index, 1, ())[0]
|
|
626
|
+
|
|
627
|
+
def shift(self) -> Any:
|
|
628
|
+
self._check()
|
|
629
|
+
return self._node.splice(0, 1, ())[0]
|
|
630
|
+
|
|
631
|
+
def unshift(self, *values: Any) -> None:
|
|
632
|
+
self._check()
|
|
633
|
+
self._node.splice(0, 0, values)
|
|
634
|
+
|
|
635
|
+
def remove(self, value: Any) -> None:
|
|
636
|
+
self._check()
|
|
637
|
+
target = self._draft._plain(value)
|
|
638
|
+
for index, current in enumerate(self._node.values()):
|
|
639
|
+
if _same_value(current, target):
|
|
640
|
+
self._node.splice(index, 1, ())
|
|
641
|
+
return
|
|
642
|
+
raise ValueError(f"{target!r} is not in list")
|
|
643
|
+
|
|
644
|
+
def clear(self) -> None:
|
|
645
|
+
self._check()
|
|
646
|
+
self._node.splice(0, self._node.length(), ())
|
|
647
|
+
|
|
648
|
+
def reverse(self) -> None:
|
|
649
|
+
self._check()
|
|
650
|
+
self._node.reverse()
|
|
651
|
+
|
|
652
|
+
def sort(self, *, key: Any = None, reverse: bool = False) -> None:
|
|
653
|
+
self._check()
|
|
654
|
+
self._node.sort(key=key, reverse=reverse)
|
|
655
|
+
|
|
656
|
+
def index(self, value: Any, start: int = 0, stop: int | None = None) -> int:
|
|
657
|
+
self._check()
|
|
658
|
+
target = self._draft._plain(value)
|
|
659
|
+
values = self._node.values()
|
|
660
|
+
end = len(values) if stop is None else stop
|
|
661
|
+
for index in range(start, min(end, len(values))):
|
|
662
|
+
if _same_value(values[index], target):
|
|
663
|
+
return index
|
|
664
|
+
raise ValueError(f"{target!r} is not in list")
|
|
665
|
+
|
|
666
|
+
def count(self, value: Any) -> int:
|
|
667
|
+
self._check()
|
|
668
|
+
target = self._draft._plain(value)
|
|
669
|
+
return sum(1 for current in self._node.values() if _same_value(current, target))
|
|
670
|
+
|
|
671
|
+
def snapshot(self) -> list[Any]:
|
|
672
|
+
"""A detached plain JSON array of this draft member."""
|
|
673
|
+
|
|
674
|
+
self._check()
|
|
675
|
+
return self._node.values()
|
|
676
|
+
|
|
677
|
+
def __contains__(self, value: object) -> bool:
|
|
678
|
+
self._check()
|
|
679
|
+
target = self._draft._plain(value)
|
|
680
|
+
return any(_same_value(current, target) for current in self._node.values())
|
|
681
|
+
|
|
682
|
+
def __eq__(self, other: object) -> bool:
|
|
683
|
+
return self._node.values() == (other._node.values() if isinstance(other, DraftList) else other)
|
|
684
|
+
|
|
685
|
+
def __repr__(self) -> str:
|
|
686
|
+
return f"DraftList({self._node.values()!r})"
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
_INTERNAL = frozenset(
|
|
690
|
+
{
|
|
691
|
+
"_check",
|
|
692
|
+
"_draft",
|
|
693
|
+
"_node",
|
|
694
|
+
"_plain",
|
|
695
|
+
"_place",
|
|
696
|
+
"_sealed",
|
|
697
|
+
}
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class DocumentDraft(DraftObject):
|
|
702
|
+
"""Root draft for one document acquisition in a transaction."""
|
|
703
|
+
|
|
704
|
+
__slots__ = ("_sealed",)
|
|
705
|
+
|
|
706
|
+
def __init__(self, value: JsonObject) -> None:
|
|
707
|
+
if not isinstance(value, dict):
|
|
708
|
+
raise TypeError("Document draft roots must be JSON objects")
|
|
709
|
+
object.__setattr__(self, "_draft", self)
|
|
710
|
+
object.__setattr__(self, "_node", _ObjectNode(self, value))
|
|
711
|
+
object.__setattr__(self, "_sealed", False)
|
|
712
|
+
|
|
713
|
+
def _check_open(self) -> None:
|
|
714
|
+
if self._sealed:
|
|
715
|
+
raise RuntimeError("Transaction has settled")
|
|
716
|
+
|
|
717
|
+
def _check(self) -> None:
|
|
718
|
+
self._check_open()
|
|
719
|
+
|
|
720
|
+
def _view(self, node: _ObjectNode | _ArrayNode) -> Any:
|
|
721
|
+
if isinstance(node, _ObjectNode):
|
|
722
|
+
return DraftObject(self, node)
|
|
723
|
+
return DraftList(self, node)
|
|
724
|
+
|
|
725
|
+
def _node_for(self, value: Any) -> _ObjectNode | _ArrayNode:
|
|
726
|
+
if isinstance(value, dict):
|
|
727
|
+
return _ObjectNode(self, value)
|
|
728
|
+
return _ArrayNode(self, value)
|
|
729
|
+
|
|
730
|
+
def _plain(self, value: Any) -> Any:
|
|
731
|
+
if isinstance(value, DocumentDraft):
|
|
732
|
+
return value.snapshot()
|
|
733
|
+
if isinstance(value, DraftObject):
|
|
734
|
+
return value._node.snapshot()
|
|
735
|
+
if isinstance(value, DraftList):
|
|
736
|
+
return value._node.values()
|
|
737
|
+
return value
|
|
738
|
+
|
|
739
|
+
def _place(self, value: Any) -> JsonValue:
|
|
740
|
+
return copy_json(self._plain(value))
|
|
741
|
+
|
|
742
|
+
def snapshot(self) -> dict[str, Any]:
|
|
743
|
+
return self._node.snapshot()
|
|
744
|
+
|
|
745
|
+
def _seal(self) -> None:
|
|
746
|
+
object.__setattr__(self, "_sealed", True)
|
|
747
|
+
|
|
748
|
+
def _prepare(self) -> PreparedDraft:
|
|
749
|
+
"""Seal the draft and return its detached value plus Chord-compatible operations."""
|
|
750
|
+
|
|
751
|
+
self._check_open()
|
|
752
|
+
self._seal()
|
|
753
|
+
value = cast(JsonObject, self._node.materialize())
|
|
754
|
+
ops: list[Op] = []
|
|
755
|
+
self._node.emit([], ops)
|
|
756
|
+
# Chord folds a batch larger than its operation budget into one root replacement.
|
|
757
|
+
if len(ops) > MAX_DELTA_OPERATIONS:
|
|
758
|
+
ops = [["r", value]]
|
|
759
|
+
return PreparedDraft(value=value, ops=ops)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
__all__ = ["DocumentDraft", "DraftList", "DraftObject", "PreparedDraft"]
|