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,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
|
+
]
|