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,324 @@
|
|
|
1
|
+
"""Conversation view mount: the active transcript and the built-in documents (spec §9.3).
|
|
2
|
+
|
|
3
|
+
The Harness keeps at most one mount per conversation. Its first observer builds the revision on
|
|
4
|
+
the Session line from committed state, and the mount advances from the Session's commit
|
|
5
|
+
publications, so every delivered frame is already durable. Document operations keep their exact
|
|
6
|
+
shape under the mount path; entries keep the raw active-transcript shape of ``ContextView``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import Any, TypedDict, cast
|
|
16
|
+
|
|
17
|
+
from ..context import Context, without_abort_signal
|
|
18
|
+
from ..delta import Op, apply_immutable
|
|
19
|
+
from ..ids import ConversationId, DocumentId
|
|
20
|
+
from ..json import JsonObject
|
|
21
|
+
from ..session.observation import CommittedState, CommittedWatch
|
|
22
|
+
from ..session.session import SessionImpl, contained
|
|
23
|
+
from ..types import (
|
|
24
|
+
CommitPublication,
|
|
25
|
+
ConversationRecord,
|
|
26
|
+
EntryRecord,
|
|
27
|
+
Storage,
|
|
28
|
+
)
|
|
29
|
+
from .agent import AgentDoc
|
|
30
|
+
from .context import capture_context_bounds, derive_context
|
|
31
|
+
from .inbox import InboxDoc
|
|
32
|
+
from .live import LiveDoc
|
|
33
|
+
from .provider import ProviderDoc
|
|
34
|
+
from .usage import UsageDoc
|
|
35
|
+
from .util import cancellation_error, closed_error
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConversationView(TypedDict):
|
|
39
|
+
"""Structural mount of one conversation's active transcript and built-in documents.
|
|
40
|
+
|
|
41
|
+
``conversation`` is the immutable record; ``entries`` is the head marker followed by the
|
|
42
|
+
non-head entries from its head; ``docs`` holds ``pi.agent``, ``pi.live``, ``pi.inbox``,
|
|
43
|
+
``pi.provider``, and ``pi.usage`` keyed by kind, with absent documents absent.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
conversation: ConversationRecord
|
|
47
|
+
entries: list[EntryRecord]
|
|
48
|
+
docs: dict[str, JsonObject]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MOUNTED: tuple[Any, ...] = (
|
|
52
|
+
AgentDoc,
|
|
53
|
+
LiveDoc,
|
|
54
|
+
InboxDoc,
|
|
55
|
+
ProviderDoc,
|
|
56
|
+
UsageDoc,
|
|
57
|
+
)
|
|
58
|
+
"""The built-in conversation documents every view mounts."""
|
|
59
|
+
|
|
60
|
+
MOUNTED_KINDS = frozenset(token.definition.kind for token in MOUNTED)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class Mount:
|
|
65
|
+
"""One conversation's current revision, mounted incarnations, and observers."""
|
|
66
|
+
|
|
67
|
+
value: ConversationView
|
|
68
|
+
docs: dict[str, tuple[DocumentId, int]]
|
|
69
|
+
observers: set[Any] = field(default_factory=set)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConversationViews:
|
|
73
|
+
"""The Harness's conversation view mounts, one per conversation while observed."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, session: SessionImpl, storage: Storage) -> None:
|
|
76
|
+
self._session = session
|
|
77
|
+
self._storage = storage
|
|
78
|
+
self._mounts: dict[ConversationId, Mount] = {}
|
|
79
|
+
self._closed = False
|
|
80
|
+
session.observe_commits(self._on_publication)
|
|
81
|
+
session.subscribe_close(self._on_close)
|
|
82
|
+
|
|
83
|
+
def report(self, error: BaseException) -> None:
|
|
84
|
+
self._session.report(error)
|
|
85
|
+
|
|
86
|
+
async def state(self, id: ConversationId, context: Context) -> CommittedState:
|
|
87
|
+
"""A disposable read-only state of the view."""
|
|
88
|
+
|
|
89
|
+
return await self.attach(id, _state_observer, context)
|
|
90
|
+
|
|
91
|
+
async def watch(self, id: ConversationId, context: Context) -> CommittedWatch:
|
|
92
|
+
"""A serialized exact-frame watch of the view; cancelling ``context`` stops it."""
|
|
93
|
+
|
|
94
|
+
observer = await self.attach(id, _watch_observer, context)
|
|
95
|
+
signal = context.abort_signal
|
|
96
|
+
if signal is not None and signal.aborted:
|
|
97
|
+
observer.cancel()
|
|
98
|
+
raise cancellation_error(signal)
|
|
99
|
+
if signal is not None:
|
|
100
|
+
observer.observe_cancellation(signal)
|
|
101
|
+
return observer
|
|
102
|
+
|
|
103
|
+
async def attach(
|
|
104
|
+
self,
|
|
105
|
+
id: ConversationId,
|
|
106
|
+
create: Callable[
|
|
107
|
+
[ConversationView, Callable[[], None], Storage, ConversationViews],
|
|
108
|
+
Any,
|
|
109
|
+
],
|
|
110
|
+
context: Context,
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Register an observer created from the current revision, atomically on the Session line."""
|
|
113
|
+
|
|
114
|
+
async def job() -> Any:
|
|
115
|
+
self._session.assert_usable()
|
|
116
|
+
mount = self._mounts.get(id)
|
|
117
|
+
if mount is None:
|
|
118
|
+
mount = await self._build(id, context)
|
|
119
|
+
detached = False
|
|
120
|
+
|
|
121
|
+
def detach() -> None:
|
|
122
|
+
nonlocal detached
|
|
123
|
+
if detached:
|
|
124
|
+
return
|
|
125
|
+
detached = True
|
|
126
|
+
mount.observers.discard(observer)
|
|
127
|
+
if not mount.observers and self._mounts.get(id) is mount:
|
|
128
|
+
del self._mounts[id]
|
|
129
|
+
|
|
130
|
+
observer = create(mount.value, detach, self._storage, self)
|
|
131
|
+
if asyncio.iscoroutine(observer):
|
|
132
|
+
observer = await observer
|
|
133
|
+
# Close or cancellation may begin while the mount hydrates; register nothing then.
|
|
134
|
+
if self._closed:
|
|
135
|
+
raise closed_error(self._session)
|
|
136
|
+
self._session.assert_usable()
|
|
137
|
+
signal = context.abort_signal
|
|
138
|
+
if signal is not None and signal.aborted:
|
|
139
|
+
raise cancellation_error(signal)
|
|
140
|
+
self._mounts[id] = mount
|
|
141
|
+
mount.observers.add(observer)
|
|
142
|
+
return observer
|
|
143
|
+
|
|
144
|
+
return await self._session.read_on_line(job)
|
|
145
|
+
|
|
146
|
+
async def _build(self, id: ConversationId, context: Context) -> Mount:
|
|
147
|
+
conversation = await self._storage.conversation(id, context)
|
|
148
|
+
if conversation is None:
|
|
149
|
+
raise RuntimeError(f"Conversation {id} does not exist")
|
|
150
|
+
bounds = await capture_context_bounds(self._storage, id, context)
|
|
151
|
+
derived = await derive_context(self._storage, id, bounds, context)
|
|
152
|
+
entries = list(derived["entries"])
|
|
153
|
+
docs: dict[str, JsonObject] = {}
|
|
154
|
+
incarnations: dict[str, tuple[DocumentId, int]] = {}
|
|
155
|
+
for token in MOUNTED:
|
|
156
|
+
loaded = await self._session._document_on_line(token, id, context)
|
|
157
|
+
if loaded is None:
|
|
158
|
+
continue
|
|
159
|
+
kind = token.definition.kind
|
|
160
|
+
docs[kind] = loaded.value
|
|
161
|
+
incarnations[kind] = (loaded.record["id"], loaded.value_version)
|
|
162
|
+
value: ConversationView = {
|
|
163
|
+
"conversation": conversation,
|
|
164
|
+
"entries": entries,
|
|
165
|
+
"docs": docs,
|
|
166
|
+
}
|
|
167
|
+
return Mount(value=value, docs=incarnations)
|
|
168
|
+
|
|
169
|
+
def _on_publication(self, publication: CommitPublication, context: Context) -> None:
|
|
170
|
+
for id, mount in list(self._mounts.items()):
|
|
171
|
+
advance_mount(id, mount, publication, context, self.report)
|
|
172
|
+
|
|
173
|
+
def _on_close(self) -> None:
|
|
174
|
+
self._closed = True
|
|
175
|
+
failure = self._session.failure
|
|
176
|
+
for mount in list(self._mounts.values()):
|
|
177
|
+
for observer in list(mount.observers):
|
|
178
|
+
contained(partial(observer.close_session, failure), self.report)
|
|
179
|
+
self._mounts.clear()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _state_observer(
|
|
183
|
+
value: ConversationView,
|
|
184
|
+
release: Callable[[], None],
|
|
185
|
+
_storage: Storage,
|
|
186
|
+
views: ConversationViews,
|
|
187
|
+
) -> CommittedState:
|
|
188
|
+
return CommittedState(value, release, views.report)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _watch_observer(
|
|
192
|
+
value: ConversationView,
|
|
193
|
+
release: Callable[[], None],
|
|
194
|
+
_storage: Storage,
|
|
195
|
+
views: ConversationViews,
|
|
196
|
+
) -> CommittedWatch:
|
|
197
|
+
return CommittedWatch(value, release, views.report)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def advance_mount(
|
|
201
|
+
id: ConversationId,
|
|
202
|
+
mount: Mount,
|
|
203
|
+
publication: CommitPublication,
|
|
204
|
+
context: Context,
|
|
205
|
+
report: Callable[[BaseException], None],
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Derive the mount's operations from one publication, apply them, and publish the revision."""
|
|
208
|
+
|
|
209
|
+
doc_ops: list[Op] = []
|
|
210
|
+
entry_ops: list[Op] = []
|
|
211
|
+
entries: list[EntryRecord] = mount.value["entries"]
|
|
212
|
+
docs = mount.value["docs"]
|
|
213
|
+
# Entry writes are published in ID order.
|
|
214
|
+
for change in publication["changes"]:
|
|
215
|
+
if change["type"] == "entry" and change["value"]["conversationId"] == id:
|
|
216
|
+
entry = change["value"]
|
|
217
|
+
target = entry.get("head")
|
|
218
|
+
if target is None:
|
|
219
|
+
entry_ops.append(["p", ["entries"], len(entries), 0, [entry]])
|
|
220
|
+
entries = [*entries, entry]
|
|
221
|
+
continue
|
|
222
|
+
# A head marker keeps the non-head entries from its head, always a suffix, and goes in front.
|
|
223
|
+
kept = next(
|
|
224
|
+
(
|
|
225
|
+
index
|
|
226
|
+
for index, candidate in enumerate(entries)
|
|
227
|
+
if "head" not in candidate and candidate["id"] >= target
|
|
228
|
+
),
|
|
229
|
+
len(entries),
|
|
230
|
+
)
|
|
231
|
+
entry_ops.append(["p", ["entries"], 0, kept, [entry]])
|
|
232
|
+
entries = [entry, *entries[kept:]]
|
|
233
|
+
continue
|
|
234
|
+
if change["type"] != "document" or change["conversationId"] != id:
|
|
235
|
+
continue
|
|
236
|
+
kind = change["record"]["kind"]
|
|
237
|
+
if kind not in MOUNTED_KINDS or "key" in change["record"]:
|
|
238
|
+
continue
|
|
239
|
+
path = ["docs", kind]
|
|
240
|
+
mounted = mount.docs.get(kind)
|
|
241
|
+
if change["value"] is None:
|
|
242
|
+
if mounted is None or mounted[0] != change["record"]["id"]:
|
|
243
|
+
continue
|
|
244
|
+
del mount.docs[kind]
|
|
245
|
+
docs = {current: value for current, value in docs.items() if current != kind}
|
|
246
|
+
doc_ops.append(["d", path])
|
|
247
|
+
elif (
|
|
248
|
+
mounted is not None
|
|
249
|
+
and mounted[0] == change["record"]["id"]
|
|
250
|
+
and mounted[1] == change["version"]
|
|
251
|
+
):
|
|
252
|
+
current = docs[kind]
|
|
253
|
+
docs = {
|
|
254
|
+
**docs,
|
|
255
|
+
kind: cast(JsonObject, apply_immutable(current, change["ops"])),
|
|
256
|
+
}
|
|
257
|
+
doc_ops.extend(_prefixed(op, path) for op in change["ops"])
|
|
258
|
+
else:
|
|
259
|
+
if change["value"] is None or change["version"] is None:
|
|
260
|
+
continue
|
|
261
|
+
mount.docs[kind] = (change["record"]["id"], change["version"])
|
|
262
|
+
docs = {**docs, kind: change["value"]}
|
|
263
|
+
doc_ops.append(["s", path, change["value"]])
|
|
264
|
+
|
|
265
|
+
ops = [*doc_ops, *entry_ops]
|
|
266
|
+
before = mount.value
|
|
267
|
+
after = before
|
|
268
|
+
frame_context = without_abort_signal(context)
|
|
269
|
+
if ops:
|
|
270
|
+
value: ConversationView = {
|
|
271
|
+
"conversation": before["conversation"],
|
|
272
|
+
"entries": entries,
|
|
273
|
+
"docs": docs,
|
|
274
|
+
}
|
|
275
|
+
mount.value = value
|
|
276
|
+
after = value
|
|
277
|
+
for observer in list(mount.observers):
|
|
278
|
+
advance = getattr(observer, "advance_frame", None) or getattr(
|
|
279
|
+
observer, "advance", None
|
|
280
|
+
)
|
|
281
|
+
if advance is not None:
|
|
282
|
+
contained(partial(advance, value, ops, frame_context), report)
|
|
283
|
+
# Every publication reaches event adapters, including one that changes no mounted state.
|
|
284
|
+
for observer in list(mount.observers):
|
|
285
|
+
publication_listener = getattr(observer, "publication", None)
|
|
286
|
+
if publication_listener is not None:
|
|
287
|
+
contained(
|
|
288
|
+
partial(
|
|
289
|
+
publication_listener,
|
|
290
|
+
before,
|
|
291
|
+
after,
|
|
292
|
+
ops,
|
|
293
|
+
publication,
|
|
294
|
+
frame_context,
|
|
295
|
+
),
|
|
296
|
+
report,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _prefixed(op: Op, prefix: list[Any]) -> Op:
|
|
301
|
+
"""Move ``op`` under ``prefix``; a root replacement becomes a set of the prefix."""
|
|
302
|
+
|
|
303
|
+
verb = op[0]
|
|
304
|
+
if verb == "r":
|
|
305
|
+
return ["s", prefix, op[1]]
|
|
306
|
+
path = [*prefix, *op[1]]
|
|
307
|
+
if verb in ("s", "a", "t"):
|
|
308
|
+
return [verb, path, op[2]]
|
|
309
|
+
if verb == "d":
|
|
310
|
+
return ["d", path]
|
|
311
|
+
if verb == "m":
|
|
312
|
+
return ["m", path, op[2]]
|
|
313
|
+
if verb == "p":
|
|
314
|
+
return ["p", path, op[2], op[3], op[4]]
|
|
315
|
+
raise TypeError(f"unknown op verb: {verb}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
__all__ = [
|
|
319
|
+
"ConversationView",
|
|
320
|
+
"ConversationViews",
|
|
321
|
+
"MOUNTED",
|
|
322
|
+
"MOUNTED_KINDS",
|
|
323
|
+
"advance_mount",
|
|
324
|
+
]
|
pi_durable/ids.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Numeric identities and commit sequences.
|
|
2
|
+
|
|
3
|
+
Brands are compile-time only, exactly as in v1.3.0: IDs and sequences remain ordinary Python
|
|
4
|
+
integers in memory, JSON, JSONL, and SQLite. ``NewType`` gives each record kind a distinct static
|
|
5
|
+
type without changing the runtime value. Apply a brand only at a trusted allocation or decoding
|
|
6
|
+
boundary through :func:`id_from_number` or :func:`seq_from_number`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import NewType, TypeVar, cast
|
|
12
|
+
|
|
13
|
+
Id = NewType("Id", int)
|
|
14
|
+
"""An erased nominal integer identifying one durable record kind."""
|
|
15
|
+
|
|
16
|
+
ConversationId = NewType("ConversationId", int)
|
|
17
|
+
EntryId = NewType("EntryId", int)
|
|
18
|
+
TaskId = NewType("TaskId", int)
|
|
19
|
+
SubmissionId = NewType("SubmissionId", int)
|
|
20
|
+
DocumentId = NewType("DocumentId", int)
|
|
21
|
+
|
|
22
|
+
Seq = NewType("Seq", int)
|
|
23
|
+
"""Strictly increasing sequence assigned to one atomic storage commit; gaps are permitted."""
|
|
24
|
+
|
|
25
|
+
ROOT_CONVERSATION_ID: ConversationId = ConversationId(1)
|
|
26
|
+
"""The root conversation always uses this reserved ID."""
|
|
27
|
+
|
|
28
|
+
IdT = TypeVar("IdT", bound=int)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def require_positive_int(value: object, label: str) -> int:
|
|
32
|
+
"""Validate an integer at a trusted numeric boundary and return it unchanged."""
|
|
33
|
+
|
|
34
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
35
|
+
raise TypeError(f"{value!r} is not a positive {label}")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def id_from_number(value: int) -> IdT: # type: ignore[type-var]
|
|
40
|
+
"""Apply an erased ID brand at a trusted numeric allocation or decoding boundary."""
|
|
41
|
+
|
|
42
|
+
return cast(IdT, require_positive_int(value, "ID"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def seq_from_number(value: int) -> Seq:
|
|
46
|
+
"""Apply the erased commit-sequence brand at a trusted storage boundary."""
|
|
47
|
+
|
|
48
|
+
return Seq(require_positive_int(value, "sequence"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"ROOT_CONVERSATION_ID",
|
|
53
|
+
"ConversationId",
|
|
54
|
+
"DocumentId",
|
|
55
|
+
"EntryId",
|
|
56
|
+
"Id",
|
|
57
|
+
"Seq",
|
|
58
|
+
"SubmissionId",
|
|
59
|
+
"TaskId",
|
|
60
|
+
"id_from_number",
|
|
61
|
+
"require_positive_int",
|
|
62
|
+
"seq_from_number",
|
|
63
|
+
]
|
pi_durable/json.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Strict JSON values and detached copies.
|
|
2
|
+
|
|
3
|
+
Durable state is JSON: records are copied into strict JSON at every ownership boundary, and a
|
|
4
|
+
value that cannot be represented without losing information is rejected. Objects are plain
|
|
5
|
+
``dict`` values, arrays are plain ``list`` values, and numbers are finite. ``bool`` is accepted
|
|
6
|
+
before ``int`` because it is an ``int`` subclass in Python, but it remains a JSON boolean.
|
|
7
|
+
|
|
8
|
+
``FrozenDict`` and ``FrozenList`` are the immutable values produced at record-construction
|
|
9
|
+
boundaries. They remain JSON-encodable (they subclass ``dict`` and ``list``) and ``copy_json``
|
|
10
|
+
turns them back into ordinary mutable JSON trees.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any, TypeAlias, TypeGuard
|
|
18
|
+
|
|
19
|
+
JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
|
|
20
|
+
"""A strict JSON value. ``Any`` is intentionally not part of the recursive alias."""
|
|
21
|
+
|
|
22
|
+
JsonObject: TypeAlias = dict[str, JsonValue]
|
|
23
|
+
"""The JSON object shape used as the root of every durable document."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FrozenDict(dict[str, Any]):
|
|
27
|
+
"""A JSON object that rejects mutation.
|
|
28
|
+
|
|
29
|
+
It subclasses ``dict`` so the standard library's JSON encoder and storage layers can consume
|
|
30
|
+
it without adapters. Deep freeze helpers produce frozen children as well, so a record cannot
|
|
31
|
+
be changed through a retained reference.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ()
|
|
35
|
+
|
|
36
|
+
def _immutable(self, *args: object, **kwargs: object) -> None:
|
|
37
|
+
raise TypeError("Frozen JSON values are immutable")
|
|
38
|
+
|
|
39
|
+
__setitem__ = _immutable
|
|
40
|
+
__delitem__ = _immutable
|
|
41
|
+
__ior__ = _immutable # type: ignore[assignment]
|
|
42
|
+
clear = _immutable
|
|
43
|
+
pop = _immutable
|
|
44
|
+
popitem = _immutable # type: ignore[assignment]
|
|
45
|
+
setdefault = _immutable
|
|
46
|
+
update = _immutable
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FrozenList(list[Any]):
|
|
50
|
+
"""A JSON array that rejects mutation while remaining a ``list``."""
|
|
51
|
+
|
|
52
|
+
__slots__ = ()
|
|
53
|
+
|
|
54
|
+
def _immutable(self, *args: object, **kwargs: object) -> None:
|
|
55
|
+
raise TypeError("Frozen JSON values are immutable")
|
|
56
|
+
|
|
57
|
+
__setitem__ = _immutable
|
|
58
|
+
__delitem__ = _immutable
|
|
59
|
+
__iadd__ = _immutable # type: ignore[assignment]
|
|
60
|
+
__imul__ = _immutable # type: ignore[assignment]
|
|
61
|
+
append = _immutable
|
|
62
|
+
clear = _immutable
|
|
63
|
+
extend = _immutable
|
|
64
|
+
insert = _immutable
|
|
65
|
+
pop = _immutable
|
|
66
|
+
remove = _immutable
|
|
67
|
+
reverse = _immutable
|
|
68
|
+
sort = _immutable
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_object(value: object) -> TypeGuard[Mapping[str, Any]]:
|
|
72
|
+
return type(value) is dict or isinstance(value, FrozenDict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_array(value: object) -> TypeGuard[list[Any]]:
|
|
76
|
+
return type(value) is list or isinstance(value, FrozenList)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_json_value(value: object) -> TypeGuard[JsonValue]:
|
|
80
|
+
"""Return whether ``value`` is strict JSON with plain, dense containers and no cycles."""
|
|
81
|
+
|
|
82
|
+
return _check(value, set())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check(value: object, ancestors: set[int]) -> bool:
|
|
86
|
+
if value is None or isinstance(value, bool) or isinstance(value, str):
|
|
87
|
+
return True
|
|
88
|
+
if isinstance(value, int):
|
|
89
|
+
return True
|
|
90
|
+
if isinstance(value, float):
|
|
91
|
+
return math.isfinite(value)
|
|
92
|
+
if _is_array(value):
|
|
93
|
+
marker = id(value)
|
|
94
|
+
if marker in ancestors:
|
|
95
|
+
return False
|
|
96
|
+
ancestors.add(marker)
|
|
97
|
+
try:
|
|
98
|
+
return all(_check(item, ancestors) for item in value)
|
|
99
|
+
finally:
|
|
100
|
+
ancestors.remove(marker)
|
|
101
|
+
if _is_object(value):
|
|
102
|
+
marker = id(value)
|
|
103
|
+
if marker in ancestors:
|
|
104
|
+
return False
|
|
105
|
+
ancestors.add(marker)
|
|
106
|
+
try:
|
|
107
|
+
for key, item in value.items():
|
|
108
|
+
if not isinstance(key, str) or not _check(item, ancestors):
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
finally:
|
|
112
|
+
ancestors.remove(marker)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def copy_json(value: object) -> JsonValue:
|
|
117
|
+
"""Return a detached strict-JSON copy of ``value``.
|
|
118
|
+
|
|
119
|
+
No container or scalar object is shared with the input. Aliases in the input become separate
|
|
120
|
+
copies in the output, and a cycle or any non-JSON value raises ``TypeError`` with a message
|
|
121
|
+
mentioning strict JSON.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
return _copy(value, set())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _copy(value: object, ancestors: set[int]) -> JsonValue:
|
|
128
|
+
if value is None or isinstance(value, bool) or isinstance(value, str):
|
|
129
|
+
return value
|
|
130
|
+
if isinstance(value, int):
|
|
131
|
+
return value
|
|
132
|
+
if isinstance(value, float):
|
|
133
|
+
if math.isfinite(value):
|
|
134
|
+
return value
|
|
135
|
+
raise TypeError("Value contains a non-finite number and is not strict JSON")
|
|
136
|
+
if _is_array(value):
|
|
137
|
+
marker = id(value)
|
|
138
|
+
if marker in ancestors:
|
|
139
|
+
raise TypeError("Value contains cycles and is not strict JSON")
|
|
140
|
+
ancestors.add(marker)
|
|
141
|
+
try:
|
|
142
|
+
return [_copy(item, ancestors) for item in value]
|
|
143
|
+
finally:
|
|
144
|
+
ancestors.remove(marker)
|
|
145
|
+
if _is_object(value):
|
|
146
|
+
marker = id(value)
|
|
147
|
+
if marker in ancestors:
|
|
148
|
+
raise TypeError("Value contains cycles and is not strict JSON")
|
|
149
|
+
ancestors.add(marker)
|
|
150
|
+
try:
|
|
151
|
+
copied: dict[str, JsonValue] = {}
|
|
152
|
+
for key, item in value.items():
|
|
153
|
+
if not isinstance(key, str):
|
|
154
|
+
raise TypeError("Value must contain strict JSON string keys")
|
|
155
|
+
copied[str(key)] = _copy(item, ancestors)
|
|
156
|
+
return copied
|
|
157
|
+
finally:
|
|
158
|
+
ancestors.remove(marker)
|
|
159
|
+
raise TypeError(f"Value contains a non-JSON {type(value).__name__}; expected strict JSON")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def freeze_json(value: object) -> JsonValue:
|
|
163
|
+
"""Validate ``value`` as strict JSON and return an immutable, alias-free tree."""
|
|
164
|
+
|
|
165
|
+
return _freeze(copy_json(value))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _freeze(value: JsonValue) -> JsonValue:
|
|
169
|
+
if isinstance(value, list):
|
|
170
|
+
return FrozenList(_freeze(item) for item in value)
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
return FrozenDict({key: _freeze(item) for key, item in value.items()})
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = [
|
|
179
|
+
"FrozenDict",
|
|
180
|
+
"FrozenList",
|
|
181
|
+
"JsonObject",
|
|
182
|
+
"JsonValue",
|
|
183
|
+
"copy_json",
|
|
184
|
+
"freeze_json",
|
|
185
|
+
"is_json_value",
|
|
186
|
+
]
|