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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. 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"]