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