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,109 @@
1
+ """Fork document copy selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, cast
7
+
8
+ from ..context import Context
9
+ from ..documents import address_id
10
+ from ..ids import ConversationId, DocumentId, EntryId
11
+ from ..types import Cursor, DocumentCopySource, DocumentCreate, Storage
12
+
13
+ _SCAN_PAGE_SIZE = 256
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ForkDocumentCopy:
18
+ """One definition-free document copy to create with a forked conversation."""
19
+
20
+ record: DocumentCreate
21
+ source: DocumentCopySource
22
+
23
+
24
+ async def prepare_fork_document_copies(
25
+ storage: Storage,
26
+ parent_conversation_id: ConversationId,
27
+ at: EntryId,
28
+ child_conversation_id: ConversationId,
29
+ context: Context,
30
+ ) -> list[ForkDocumentCopy]:
31
+ """Select every persisted conversation document copied by one fork."""
32
+
33
+ entry = await storage.entry(parent_conversation_id, at, context)
34
+ if entry is None:
35
+ raise ValueError(f"Entry {at} is not visible from conversation {parent_conversation_id}")
36
+ copies: list[ForkDocumentCopy] = []
37
+ copied_addresses: set[str] = set()
38
+ await _collect_copies(
39
+ storage,
40
+ {"kind": "conversation", "conversationId": entry["entry"]["conversationId"]},
41
+ entry["commitSeq"],
42
+ "asOf",
43
+ child_conversation_id,
44
+ copies,
45
+ copied_addresses,
46
+ context,
47
+ )
48
+ await _collect_copies(
49
+ storage,
50
+ {"kind": "conversation", "conversationId": parent_conversation_id},
51
+ "current",
52
+ "current",
53
+ child_conversation_id,
54
+ copies,
55
+ copied_addresses,
56
+ context,
57
+ )
58
+ return copies
59
+
60
+
61
+ async def _collect_copies(
62
+ storage: Storage,
63
+ scope: dict[str, Any],
64
+ at: int | str,
65
+ policy: str,
66
+ child_conversation_id: ConversationId,
67
+ copies: list[ForkDocumentCopy],
68
+ copied_addresses: set[str],
69
+ context: Context,
70
+ ) -> None:
71
+ cursor: Cursor | None = None
72
+ while True:
73
+ page = await storage.scan_documents(
74
+ {"scope": cast(Any, scope), "at": cast(Any, at)},
75
+ _SCAN_PAGE_SIZE,
76
+ cursor,
77
+ context,
78
+ )
79
+ for source in page["items"]:
80
+ if source["scope"]["kind"] != "conversation" or source.get("fork") != policy:
81
+ continue
82
+ id = cast(DocumentId, await storage.mint_id())
83
+ record: dict[str, Any] = {
84
+ "id": id,
85
+ "kind": source["kind"],
86
+ "scope": {"kind": "conversation", "conversationId": child_conversation_id},
87
+ "history": source.get("history"),
88
+ "fork": source.get("fork"),
89
+ }
90
+ if "key" in source:
91
+ record["key"] = source["key"]
92
+ created = cast(DocumentCreate, record)
93
+ copy_address = address_id(cast(Any, created))
94
+ if copy_address in copied_addresses:
95
+ member = created["kind"] if "key" not in created else f"{created['kind']}/{created['key']}"
96
+ raise ValueError(f"Fork selects multiple source documents for {member}")
97
+ copied_addresses.add(copy_address)
98
+ copies.append(
99
+ ForkDocumentCopy(
100
+ record=created,
101
+ source={"id": source["id"], "at": cast(Any, at)},
102
+ )
103
+ )
104
+ if "next" not in page:
105
+ break
106
+ cursor = page["next"]
107
+
108
+
109
+ __all__ = ["ForkDocumentCopy", "prepare_fork_document_copies"]
@@ -0,0 +1,374 @@
1
+ """Committed observation: exact-frame watches and read-only attached states.
2
+
3
+ A watch is bound to one committed revision or view mount. It queues every committed change while
4
+ its listener runs, so callbacks never overlap, and collapses more than
5
+ ``MAX_PENDING_WATCH_FRAMES`` pending frames into one root replacement. Retirement and close end
6
+ the watch with the v1.3.0 reason. A state publishes every later revision in order through its
7
+ listeners and keeps its last value at close.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from typing import Any, cast
16
+
17
+ from ..context import BACKGROUND_CONTEXT, AbortSignal, Context, without_abort_signal
18
+ from ..delta import Op
19
+ from ..errors import is_failure
20
+ from ..json import JsonObject
21
+ from ..types import DocumentCommittedChange, WatchEnd
22
+
23
+ ObservedDocumentValue = JsonObject | None
24
+ """One observed revision; ``None`` is the canonical retirement value."""
25
+
26
+ MAX_PENDING_WATCH_FRAMES = 100
27
+ """Maximum exact committed frames retained behind one unavailable watch listener."""
28
+
29
+ RETIREMENT_OPERATIONS: list[Op] = [["r", None]]
30
+ """Canonical terminal update for a retired document incarnation."""
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class Delivery:
35
+ """One state delivery: hydration at sequence 0, then one per committed update."""
36
+
37
+ kind: str
38
+ sequence: int
39
+
40
+
41
+ class CommittedState:
42
+ """Disposable, read-only committed state over one immutable value.
43
+
44
+ A document state binds it to one incarnation; a conversation view or task graph binds it to
45
+ the Harness's mount. Subscribing delivers the acquisition revision immediately and then every
46
+ later committed revision, in order, without another read.
47
+ """
48
+
49
+ __slots__ = (
50
+ "_closed",
51
+ "_disposed",
52
+ "_listeners",
53
+ "_release",
54
+ "_released",
55
+ "_report",
56
+ "_sequence",
57
+ "_value",
58
+ )
59
+
60
+ def __init__(
61
+ self,
62
+ value: Any,
63
+ release: Callable[[], None],
64
+ report: Callable[[BaseException], None] | None = None,
65
+ ) -> None:
66
+ self._value = value
67
+ self._release = release
68
+ self._report = report
69
+ self._released = False
70
+ self._sequence = 0
71
+ self._listeners: list[Callable[[Any, Context, Delivery], Any]] = []
72
+ self._disposed = False
73
+ self._closed = False
74
+
75
+ @property
76
+ def value(self) -> Any:
77
+ return self._value
78
+
79
+ def subscribe(self, listener: Callable[[Any, Context, Delivery], Any]) -> Callable[[], None]:
80
+ """Subscribe; hydration is delivered immediately with sequence 0."""
81
+
82
+ if self._disposed:
83
+ raise RuntimeError("State is disposed")
84
+ self._listeners.append(listener)
85
+ self._deliver(listener, self._value, BACKGROUND_CONTEXT, Delivery("hydrate", self._sequence))
86
+
87
+ def unsubscribe() -> None:
88
+ try:
89
+ self._listeners.remove(listener)
90
+ except ValueError:
91
+ pass
92
+
93
+ return unsubscribe
94
+
95
+ def dispose(self) -> None:
96
+ """Idempotently stop future deliveries; the last value remains readable."""
97
+
98
+ if self._disposed:
99
+ return
100
+ self._disposed = True
101
+ self._listeners.clear()
102
+ self._release_pin()
103
+
104
+ def _release_pin(self) -> None:
105
+ """Release the observer once; a retired incarnation keeps its last value but pins nothing."""
106
+
107
+ if self._released:
108
+ return
109
+ self._released = True
110
+ self._release()
111
+
112
+ def advance(self, value: Any, _ops: list[Op], context: Context) -> None:
113
+ """Publish one exact committed revision to every listener."""
114
+
115
+ self._advance(value, context)
116
+
117
+ def _advance(self, value: Any, context: Context) -> None:
118
+ if self._disposed or self._closed:
119
+ return
120
+ self._value = value
121
+ self._sequence += 1
122
+ delivery = Delivery("update", self._sequence)
123
+ for listener in list(self._listeners):
124
+ self._deliver(listener, value, without_abort_signal(context), delivery)
125
+
126
+ def close_session(self, _failure: BaseException | None = None) -> None:
127
+ """An observer's Session-close callback; the last value and this state remain."""
128
+
129
+ self._close_session()
130
+
131
+ def _close_session(self) -> None:
132
+ """Keep the last value at the Session's seal and receive no commit that settles later."""
133
+
134
+ if self._closed:
135
+ return
136
+ self._closed = True
137
+ self._listeners.clear()
138
+ self._release_pin()
139
+
140
+ def _deliver(
141
+ self,
142
+ listener: Callable[[Any, Context, Delivery], Any],
143
+ value: Any,
144
+ context: Context,
145
+ delivery: Delivery,
146
+ ) -> None:
147
+ try:
148
+ result = listener(value, context, delivery)
149
+ if hasattr(result, "__await__"):
150
+ asyncio.ensure_future(cast(Awaitable[Any], result))
151
+ except Exception as error:
152
+ if self._report is not None:
153
+ self._report(error)
154
+
155
+
156
+ def observed_operations(
157
+ observed_version: int,
158
+ change: DocumentCommittedChange,
159
+ ) -> tuple[list[Op], int]:
160
+ """Return the operations one observer applies for a committed change, and its version.
161
+
162
+ An observer hydrated under another definition version holds a differently shaped value, so it
163
+ receives the new value as a root replacement instead of operations for that shape.
164
+ """
165
+
166
+ if change["value"] is None:
167
+ return RETIREMENT_OPERATIONS, observed_version
168
+ version = cast(int, change["version"])
169
+ if version == observed_version:
170
+ return change["ops"], observed_version
171
+ return [["r", change["value"]]], version
172
+
173
+
174
+ class CommittedWatch:
175
+ """Serialized exact-frame watch bound to one document incarnation (spec §9.2)."""
176
+
177
+ __slots__ = (
178
+ "_cancellation_remove",
179
+ "_cancellation_signal",
180
+ "_closed",
181
+ "_terminal",
182
+ "_detach",
183
+ "_end",
184
+ "_listener",
185
+ "_observed_version",
186
+ "_pending",
187
+ "_release",
188
+ "_replace",
189
+ "_report",
190
+ "_retired",
191
+ "_running",
192
+ "_scheduled",
193
+ "_started",
194
+ "_value",
195
+ )
196
+
197
+ def __init__(
198
+ self,
199
+ value: Any,
200
+ detach: Callable[[], None],
201
+ report: Callable[[BaseException], None],
202
+ observed_version: int = 0,
203
+ replace: Callable[[], Any] | None = None,
204
+ ) -> None:
205
+ self._value: Any = value
206
+ self._detach = detach
207
+ self._report = report
208
+ self._observed_version = observed_version
209
+ self._replace = replace
210
+ self._closed: asyncio.Future[WatchEnd] = asyncio.get_running_loop().create_future()
211
+ # Awaiting a cancellation must not cancel the watch's own terminal result; `closed` and
212
+ # every `stop()` return this same shielded value, as v1.3.0 returns one common promise.
213
+ self._terminal: asyncio.Future[WatchEnd] = asyncio.shield(self._closed)
214
+ self._pending: list[tuple[Any, list[Op], Context]] = []
215
+ self._listener: Callable[[Any, list[Op], Context], Any] | None = None
216
+ self._started = False
217
+ self._scheduled = False
218
+ self._running = False
219
+ self._retired = False
220
+ self._end: WatchEnd | None = None
221
+ self._cancellation_signal: AbortSignal | None = None
222
+ self._cancellation_remove: Callable[[], None] | None = None
223
+
224
+ @property
225
+ def value(self) -> Any:
226
+ """Acquisition revision before start; latest delivered revision afterward."""
227
+
228
+ return self._value
229
+
230
+ @property
231
+ def closed(self) -> asyncio.Future[WatchEnd]:
232
+ return self._terminal
233
+
234
+ @property
235
+ def retired(self) -> bool:
236
+ """Whether the observed incarnation retired; a retired observer pins nothing."""
237
+
238
+ return self._retired
239
+
240
+ def start(
241
+ self,
242
+ listener: Callable[[Any, list[Op], Context], Awaitable[None] | None],
243
+ ) -> None:
244
+ """Install the sole asynchronous listener. Never invokes it inline."""
245
+
246
+ if self._started:
247
+ raise RuntimeError("Watch is already started")
248
+ if self._end is not None:
249
+ raise RuntimeError("Watch is stopped")
250
+ self._started = True
251
+ self._listener = listener
252
+ if self._pending:
253
+ self._schedule()
254
+
255
+ def stop(self) -> asyncio.Future[WatchEnd]:
256
+ """Idempotently stop future callbacks and return this watch's terminal result."""
257
+
258
+ self._terminate({"reason": "stopped"})
259
+ return self._terminal
260
+
261
+ def observe_cancellation(self, signal: AbortSignal) -> None:
262
+ if self._cancellation_signal is not None:
263
+ raise RuntimeError("Watch cancellation is already installed")
264
+ if self._end is not None:
265
+ return
266
+ self._cancellation_signal = signal
267
+ self._cancellation_remove = signal.add_listener(self.cancel)
268
+ if signal.aborted:
269
+ self.cancel()
270
+
271
+ def cancel(self) -> None:
272
+ self._terminate({"reason": "cancelled"})
273
+
274
+ def close_session(self, failure: BaseException | None = None) -> None:
275
+ """End with ``session_closed``, or with ``session_failed`` and the error on failure."""
276
+
277
+ if failure is None:
278
+ self._terminate({"reason": "session_closed"})
279
+ else:
280
+ self._terminate({"reason": "session_failed", "error": failure})
281
+
282
+ def advance(self, change: DocumentCommittedChange, context: Context) -> None:
283
+ """Queue the committed document change, skipping one this observer already holds."""
284
+
285
+ if self._end is not None or self._retired:
286
+ return
287
+ ops, version = observed_operations(self._observed_version, change)
288
+ if not ops:
289
+ return
290
+ self._observed_version = version
291
+ self.advance_frame(change["value"], ops, context)
292
+
293
+ def advance_frame(self, value: Any, ops: list[Op], context: Context) -> None:
294
+ """Queue one exact committed frame for a view, graph, or event stream."""
295
+
296
+ if self._end is not None or self._retired:
297
+ return
298
+ if value is None:
299
+ self._retired = True
300
+ if len(self._pending) >= MAX_PENDING_WATCH_FRAMES:
301
+ self._pending.clear()
302
+ replacement = self._replace() if self._replace is not None else value
303
+ self._pending.append((replacement, [["r", replacement]], context))
304
+ else:
305
+ self._pending.append((value, ops, context))
306
+ if self._started:
307
+ self._schedule()
308
+
309
+ def _schedule(self) -> None:
310
+ if self._scheduled or self._running or self._end is not None:
311
+ return
312
+ self._scheduled = True
313
+ asyncio.get_running_loop().call_soon(self._launch)
314
+
315
+ def _launch(self) -> None:
316
+ self._scheduled = False
317
+ if self._end is not None or self._running or not self._started:
318
+ return
319
+ self._running = True
320
+ asyncio.get_running_loop().create_task(self._drain())
321
+
322
+ async def _drain(self) -> None:
323
+ try:
324
+ while self._end is None and self._pending:
325
+ value, ops, context = self._pending.pop(0)
326
+ self._value = value
327
+ listener = self._listener
328
+ assert listener is not None
329
+ try:
330
+ result = listener(value, ops, without_abort_signal(context))
331
+ if isinstance(result, Awaitable):
332
+ await result
333
+ except BaseException as error:
334
+ if not is_failure(error):
335
+ raise
336
+ if self._end is None:
337
+ self._report(error)
338
+ self._terminate({"reason": "listener_error", "error": error})
339
+ break
340
+ if value is None:
341
+ self._terminate({"reason": "retired"})
342
+ break
343
+ finally:
344
+ self._running = False
345
+ if self._end is None and self._pending:
346
+ self._schedule()
347
+
348
+ def _terminate(self, end: WatchEnd) -> None:
349
+ """End the watch, once: detach, drop pending frames, and settle ``closed``."""
350
+
351
+ if self._end is not None:
352
+ return
353
+ self._end = end
354
+ self._pending.clear()
355
+ if self._cancellation_remove is not None:
356
+ remove = self._cancellation_remove
357
+ self._cancellation_remove = None
358
+ remove()
359
+ try:
360
+ self._detach()
361
+ finally:
362
+ if not self._closed.done():
363
+ self._closed.set_result(end)
364
+
365
+
366
+ __all__ = [
367
+ "CommittedState",
368
+ "CommittedWatch",
369
+ "Delivery",
370
+ "MAX_PENDING_WATCH_FRAMES",
371
+ "ObservedDocumentValue",
372
+ "RETIREMENT_OPERATIONS",
373
+ "observed_operations",
374
+ ]