qplotter 1.6.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 (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1745 @@
1
+ """Bounded primitive-only IPC for the trusted live-reader helper.
2
+
3
+ The multiprocessing bootstrap necessarily transfers Python ``Connection``
4
+ handles to the spawned child. After bootstrap, every application frame is
5
+ canonical UTF-8 JSON sent with ``send_bytes``/``recv_bytes``. This module is
6
+ the sole encoder/decoder for that wire format.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import json
14
+ import math
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from typing import Any, Final, TypeAlias
18
+
19
+ from qplot.datahandling.file_identity import (
20
+ MAX_DATABASE_SIDECAR_IDENTITIES,
21
+ DatabaseFileIdentity,
22
+ DatabaseInstance,
23
+ )
24
+ from qplot.datahandling.trusted_live import (
25
+ TRUSTED_LIVE_MAX_BATCH_QUERIES,
26
+ TRUSTED_LIVE_MAX_CELLS_PER_REPLY,
27
+ TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES,
28
+ TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT,
29
+ TRUSTED_LIVE_MAX_REPLY_BYTES,
30
+ TRUSTED_LIVE_MAX_ROWS_PER_RESULT,
31
+ TRUSTED_LIVE_MAX_SCALAR_BYTES,
32
+ SqliteBindings,
33
+ TrustedLiveReaderError,
34
+ TrustedLiveSourceIdentity,
35
+ TrustedQuery,
36
+ TrustedQueryResult,
37
+ preflight_trusted_query_results,
38
+ )
39
+
40
+ PROTOCOL_VERSION: Final = 1
41
+ MAX_REQUEST_BYTES: Final = 1 * 1024 * 1024
42
+ MAX_CONTROL_BYTES: Final = 4 * 1024
43
+ MAX_REPLY_BYTES: Final = TRUSTED_LIVE_MAX_REPLY_BYTES
44
+ MAX_SQL_BYTES: Final = 256 * 1024
45
+ MAX_BATCH_QUERIES: Final = TRUSTED_LIVE_MAX_BATCH_QUERIES
46
+ MAX_BINDINGS_PER_QUERY: Final = 4_096
47
+ MAX_BINDING_NAME_BYTES: Final = TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES
48
+ MAX_COLUMNS_PER_RESULT: Final = TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT
49
+ MAX_ROWS_PER_RESULT: Final = TRUSTED_LIVE_MAX_ROWS_PER_RESULT
50
+ MAX_CELLS_PER_REPLY: Final = TRUSTED_LIVE_MAX_CELLS_PER_REPLY
51
+ MAX_SCALAR_BYTES: Final = TRUSTED_LIVE_MAX_SCALAR_BYTES
52
+ MAX_ERROR_MESSAGE_BYTES: Final = 16 * 1024
53
+ MAX_PATH_BYTES: Final = 32 * 1024
54
+ MAX_JSON_NESTING: Final = 12
55
+ MAX_JSON_COLLECTION_ITEMS: Final = 4_500_000
56
+ MAX_GENERATION: Final = (1 << 63) - 1
57
+ SESSION_HEX_CHARS: Final = 32
58
+ MIN_OPERATION_TIMEOUT_MS: Final = 0
59
+ MAX_OPERATION_TIMEOUT_MS: Final = 300_000
60
+
61
+ _INTEGER_MIN = -(1 << 63)
62
+ _INTEGER_MAX = (1 << 63) - 1
63
+ _IDENTITY_INTEGER_MAX = (1 << 64) - 1
64
+ _MAX_REAL_HEX_CHARS = 32
65
+ _MAX_BASE64_SCALAR_CHARS = 4 * ((MAX_SCALAR_BYTES + 2) // 3)
66
+ _MAX_BUSY_TIMEOUT_MS = (1 << 31) - 1
67
+ _MIN_STARTUP_TIMEOUT_MS = 1
68
+ _MIN_ORPHAN_GRACE_MS = 1
69
+ _FRAME_ENVELOPE_RESERVE_BYTES = 512
70
+ _WIRE_CONTAINER_BUDGET_BYTES = 32
71
+ _WIRE_FIELD_BUDGET_BYTES = 16
72
+ _LOWERCASE_HEX_DIGITS = frozenset("0123456789abcdef")
73
+ _REQUEST_OPERATIONS = frozenset(
74
+ {"startup", "query", "query_batch", "data_version", "shutdown"}
75
+ )
76
+ _REPLY_OPERATIONS = _REQUEST_OPERATIONS | {"startup", "protocol"}
77
+ _CONTROL_OPERATIONS = frozenset({"cancel"})
78
+ _ERROR_CODES = frozenset(
79
+ {
80
+ "reader_unavailable",
81
+ "unsupported_source",
82
+ "source_changed",
83
+ "source_io",
84
+ "sql_rejected",
85
+ "query_failed",
86
+ "result_limit",
87
+ "busy_timeout",
88
+ "cancelled",
89
+ "operation_deadline",
90
+ "invalid_database",
91
+ "cleanup_quarantine",
92
+ "reader_closed",
93
+ "reader_thread",
94
+ "transaction",
95
+ "reader_error",
96
+ "protocol_error",
97
+ "internal_error",
98
+ }
99
+ )
100
+
101
+ JsonValue: TypeAlias = (
102
+ None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
103
+ )
104
+
105
+
106
+ class TrustedLiveProtocolValidationError(ValueError):
107
+ """Raised when an IPC frame or payload fails closed validation."""
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class ProtocolEnvelope:
112
+ """One validated protocol envelope with an operation-specific payload."""
113
+
114
+ session: str
115
+ generation: int
116
+ operation: str
117
+ payload: dict[str, Any]
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class _WireSizeBudget:
122
+ """Conservatively bound aggregate canonical-wire construction."""
123
+
124
+ maximum: int
125
+ used: int = 0
126
+
127
+ def consume(self, amount: int, description: str) -> None:
128
+ if amount < 0 or amount > self.maximum - self.used:
129
+ raise TrustedLiveProtocolValidationError(
130
+ f"{description} exceeds the aggregate {self.maximum}-byte "
131
+ "protocol wire budget."
132
+ )
133
+ self.used += amount
134
+
135
+
136
+ def _new_role_budget(maximum: int) -> _WireSizeBudget:
137
+ budget = _WireSizeBudget(maximum)
138
+ budget.consume(_FRAME_ENVELOPE_RESERVE_BYTES, "The protocol envelope")
139
+ return budget
140
+
141
+
142
+ def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
143
+ result: dict[str, Any] = {}
144
+ for key, value in pairs:
145
+ if key in result:
146
+ raise TrustedLiveProtocolValidationError(
147
+ f"Duplicate JSON object key {key!r} was rejected."
148
+ )
149
+ result[key] = value
150
+ return result
151
+
152
+
153
+ def _text_sizes(value: str, description: str, maximum: int) -> tuple[int, int]:
154
+ """Return UTF-8 and canonical JSON-string sizes without allocating either."""
155
+
156
+ utf8_size = 0
157
+ json_size = 2 # Opening and closing quotes.
158
+ for character in value:
159
+ codepoint = ord(character)
160
+ if 0xD800 <= codepoint <= 0xDFFF:
161
+ raise TrustedLiveProtocolValidationError(
162
+ f"{description} must contain valid Unicode text."
163
+ )
164
+ if codepoint <= 0x7F:
165
+ character_bytes = 1
166
+ elif codepoint <= 0x7FF:
167
+ character_bytes = 2
168
+ elif codepoint <= 0xFFFF:
169
+ character_bytes = 3
170
+ else:
171
+ character_bytes = 4
172
+ utf8_size += character_bytes
173
+ if utf8_size > maximum:
174
+ raise TrustedLiveProtocolValidationError(
175
+ f"{description} exceeds the {maximum}-byte protocol limit."
176
+ )
177
+ if character in {'"', "\\"}:
178
+ json_size += 2
179
+ elif codepoint <= 0x1F:
180
+ json_size += 2 if character in "\b\t\n\f\r" else 6
181
+ else:
182
+ json_size += character_bytes
183
+ return utf8_size, json_size
184
+
185
+
186
+ def _utf8_size(value: str, description: str, maximum: int) -> int:
187
+ return _text_sizes(value, description, maximum)[0]
188
+
189
+
190
+ def _json_string_wire_size(value: str, description: str, maximum: int) -> int:
191
+ return _text_sizes(value, description, maximum)[1]
192
+
193
+
194
+ def validate_session(session: object) -> str:
195
+ if (
196
+ type(session) is not str
197
+ or len(session) != SESSION_HEX_CHARS
198
+ or any(character not in _LOWERCASE_HEX_DIGITS for character in session)
199
+ ):
200
+ raise TrustedLiveProtocolValidationError(
201
+ "The protocol session must be exactly 32 lowercase hexadecimal characters."
202
+ )
203
+ return session
204
+
205
+
206
+ def validate_generation(generation: object, *, allow_zero: bool) -> int:
207
+ minimum = 0 if allow_zero else 1
208
+ if (
209
+ type(generation) is not int
210
+ or generation < minimum
211
+ or generation > MAX_GENERATION
212
+ ):
213
+ raise TrustedLiveProtocolValidationError(
214
+ f"The protocol generation must be an integer from {minimum} "
215
+ f"through {MAX_GENERATION}."
216
+ )
217
+ return generation
218
+
219
+
220
+ def _preflight_json_value(root: object, budget: _WireSizeBudget) -> None:
221
+ """Bound a generic primitive JSON tree before ``json.dumps`` allocates."""
222
+
223
+ active_collections: set[int] = set()
224
+ collection_items = 0
225
+
226
+ def visit(value: object, depth: int) -> None:
227
+ nonlocal collection_items
228
+ if depth > MAX_JSON_NESTING:
229
+ raise TrustedLiveProtocolValidationError(
230
+ "The protocol value exceeds the JSON nesting limit."
231
+ )
232
+ if isinstance(value, Mapping):
233
+ identity = id(value)
234
+ if identity in active_collections:
235
+ raise TrustedLiveProtocolValidationError(
236
+ "The protocol value contains a circular collection."
237
+ )
238
+ collection_items += len(value)
239
+ if collection_items > MAX_JSON_COLLECTION_ITEMS:
240
+ raise TrustedLiveProtocolValidationError(
241
+ "The protocol value contains too many collection items."
242
+ )
243
+ # Count exact JSON punctuation here. Operation-specific encoders
244
+ # have already applied their own conservative resource budgets;
245
+ # charging another arbitrary per-container reserve would reject
246
+ # live results that are safely within both that budget and the
247
+ # exact frame cap.
248
+ budget.consume(
249
+ 2 + len(value) + max(0, len(value) - 1),
250
+ "The protocol frame",
251
+ )
252
+ active_collections.add(identity)
253
+ try:
254
+ for key, child in value.items():
255
+ if type(key) is not str:
256
+ raise TrustedLiveProtocolValidationError(
257
+ "A protocol object key is not text."
258
+ )
259
+ budget.consume(
260
+ _json_string_wire_size(
261
+ key,
262
+ "A protocol object key",
263
+ budget.maximum,
264
+ ),
265
+ "The protocol frame",
266
+ )
267
+ visit(child, depth + 1)
268
+ finally:
269
+ active_collections.remove(identity)
270
+ return
271
+ if isinstance(value, (list, tuple)):
272
+ identity = id(value)
273
+ if identity in active_collections:
274
+ raise TrustedLiveProtocolValidationError(
275
+ "The protocol value contains a circular collection."
276
+ )
277
+ collection_items += len(value)
278
+ if collection_items > MAX_JSON_COLLECTION_ITEMS:
279
+ raise TrustedLiveProtocolValidationError(
280
+ "The protocol value contains too many collection items."
281
+ )
282
+ budget.consume(2 + max(0, len(value) - 1), "The protocol frame")
283
+ active_collections.add(identity)
284
+ try:
285
+ for child in value:
286
+ visit(child, depth + 1)
287
+ finally:
288
+ active_collections.remove(identity)
289
+ return
290
+ if value is None:
291
+ budget.consume(4, "The protocol frame")
292
+ return
293
+ if isinstance(value, bool):
294
+ budget.consume(5, "The protocol frame")
295
+ return
296
+ if type(value) is int:
297
+ try:
298
+ integer_size = len(str(value))
299
+ except ValueError as error:
300
+ raise TrustedLiveProtocolValidationError(
301
+ "A protocol integer is too large to encode."
302
+ ) from error
303
+ budget.consume(integer_size, "The protocol frame")
304
+ return
305
+ if isinstance(value, float):
306
+ budget.consume(32, "The protocol frame")
307
+ return
308
+ if isinstance(value, str):
309
+ budget.consume(
310
+ _json_string_wire_size(
311
+ value,
312
+ "A protocol text value",
313
+ budget.maximum,
314
+ ),
315
+ "The protocol frame",
316
+ )
317
+ return
318
+ raise TrustedLiveProtocolValidationError(
319
+ "The protocol payload is not canonical JSON primitive data."
320
+ )
321
+
322
+ # The eventual envelope object occupies nesting level one.
323
+ visit(root, 2)
324
+
325
+
326
+ def encode_frame(
327
+ session: str,
328
+ generation: int,
329
+ operation: str,
330
+ payload: Mapping[str, Any],
331
+ *,
332
+ maximum_bytes: int,
333
+ ) -> bytes:
334
+ """Encode one exact envelope and enforce its role-specific frame cap."""
335
+
336
+ validate_session(session)
337
+ validate_generation(generation, allow_zero=True)
338
+ if not isinstance(operation, str) or not operation:
339
+ raise TrustedLiveProtocolValidationError(
340
+ "The protocol operation must be non-empty text."
341
+ )
342
+ if not isinstance(payload, Mapping):
343
+ raise TrustedLiveProtocolValidationError(
344
+ "The protocol payload must be an object."
345
+ )
346
+ budget = _new_role_budget(maximum_bytes)
347
+ budget.consume(
348
+ _json_string_wire_size(
349
+ operation,
350
+ "The protocol operation",
351
+ maximum_bytes,
352
+ ),
353
+ "The protocol envelope",
354
+ )
355
+ _preflight_json_value(payload, budget)
356
+ envelope = {
357
+ "generation": generation,
358
+ "operation": operation,
359
+ "payload": dict(payload),
360
+ "protocol_version": PROTOCOL_VERSION,
361
+ "session": session,
362
+ }
363
+ try:
364
+ encoded = json.dumps(
365
+ envelope,
366
+ allow_nan=False,
367
+ ensure_ascii=False,
368
+ separators=(",", ":"),
369
+ sort_keys=True,
370
+ ).encode("utf-8", errors="strict")
371
+ except (TypeError, ValueError, UnicodeEncodeError) as error:
372
+ raise TrustedLiveProtocolValidationError(
373
+ "The protocol payload is not canonical JSON primitive data."
374
+ ) from error
375
+ if not encoded or len(encoded) > maximum_bytes:
376
+ raise TrustedLiveProtocolValidationError(
377
+ f"The encoded protocol frame exceeds the {maximum_bytes}-byte limit."
378
+ )
379
+ return encoded
380
+
381
+
382
+ def decode_frame(
383
+ frame: bytes,
384
+ *,
385
+ maximum_bytes: int,
386
+ allowed_operations: frozenset[str],
387
+ ) -> ProtocolEnvelope:
388
+ """Decode and validate the common envelope without trusting pickle."""
389
+
390
+ if type(frame) is not bytes or not frame or len(frame) > maximum_bytes:
391
+ raise TrustedLiveProtocolValidationError(
392
+ f"The protocol frame must contain 1 through {maximum_bytes} bytes."
393
+ )
394
+ try:
395
+ decoded = json.loads(
396
+ frame.decode("utf-8", errors="strict"),
397
+ object_pairs_hook=_reject_duplicate_keys,
398
+ parse_constant=lambda value: _raise_invalid_constant(value),
399
+ parse_float=_parse_finite_json_float,
400
+ )
401
+ except TrustedLiveProtocolValidationError:
402
+ raise
403
+ except (UnicodeDecodeError, ValueError, RecursionError) as error:
404
+ raise TrustedLiveProtocolValidationError(
405
+ "The protocol frame is not valid bounded UTF-8 JSON."
406
+ ) from error
407
+ try:
408
+ _validate_json_tree(decoded)
409
+ except RecursionError as error:
410
+ raise TrustedLiveProtocolValidationError(
411
+ "The protocol frame exceeds the JSON nesting limit."
412
+ ) from error
413
+ if not isinstance(decoded, dict) or set(decoded) != {
414
+ "protocol_version",
415
+ "session",
416
+ "generation",
417
+ "operation",
418
+ "payload",
419
+ }:
420
+ raise TrustedLiveProtocolValidationError(
421
+ "The protocol envelope has missing, extra, or invalid fields."
422
+ )
423
+ if type(decoded["protocol_version"]) is not int or (
424
+ decoded["protocol_version"] != PROTOCOL_VERSION
425
+ ):
426
+ raise TrustedLiveProtocolValidationError("The protocol version is unsupported.")
427
+ session = validate_session(decoded["session"])
428
+ generation = validate_generation(decoded["generation"], allow_zero=True)
429
+ operation = decoded["operation"]
430
+ if not isinstance(operation, str) or operation not in allowed_operations:
431
+ raise TrustedLiveProtocolValidationError(
432
+ "The protocol operation is unknown for this channel."
433
+ )
434
+ payload = decoded["payload"]
435
+ if not isinstance(payload, dict):
436
+ raise TrustedLiveProtocolValidationError(
437
+ "The protocol payload must be an object."
438
+ )
439
+ return ProtocolEnvelope(session, generation, operation, payload)
440
+
441
+
442
+ def _validate_json_tree(root: object) -> None:
443
+ """Bound nesting and collection fan-out before shape-specific use."""
444
+
445
+ collection_items = 0
446
+
447
+ def visit(value: object, depth: int) -> None:
448
+ nonlocal collection_items
449
+ if isinstance(value, dict):
450
+ if depth > MAX_JSON_NESTING:
451
+ raise TrustedLiveProtocolValidationError(
452
+ "The protocol frame exceeds the JSON nesting limit."
453
+ )
454
+ collection_items += len(value)
455
+ if collection_items > MAX_JSON_COLLECTION_ITEMS:
456
+ raise TrustedLiveProtocolValidationError(
457
+ "The protocol frame contains too many collection items."
458
+ )
459
+ for key, child in value.items():
460
+ if not isinstance(key, str):
461
+ raise TrustedLiveProtocolValidationError(
462
+ "A protocol object key is not text."
463
+ )
464
+ visit(child, depth + 1)
465
+ elif isinstance(value, list):
466
+ if depth > MAX_JSON_NESTING:
467
+ raise TrustedLiveProtocolValidationError(
468
+ "The protocol frame exceeds the JSON nesting limit."
469
+ )
470
+ collection_items += len(value)
471
+ if collection_items > MAX_JSON_COLLECTION_ITEMS:
472
+ raise TrustedLiveProtocolValidationError(
473
+ "The protocol frame contains too many collection items."
474
+ )
475
+ for child in value:
476
+ visit(child, depth + 1)
477
+ elif isinstance(value, float):
478
+ if not math.isfinite(value):
479
+ raise TrustedLiveProtocolValidationError(
480
+ "The protocol contains a non-finite JSON number."
481
+ )
482
+ elif value is not None and not isinstance(value, (bool, int, str)):
483
+ raise TrustedLiveProtocolValidationError(
484
+ "The protocol contains a non-primitive JSON value."
485
+ )
486
+
487
+ visit(root, 1)
488
+
489
+
490
+ def _raise_invalid_constant(value: str) -> None:
491
+ raise TrustedLiveProtocolValidationError(
492
+ f"The non-finite JSON constant {value!r} was rejected."
493
+ )
494
+
495
+
496
+ def _parse_finite_json_float(value: str) -> float:
497
+ decoded = float(value)
498
+ if not math.isfinite(decoded):
499
+ raise TrustedLiveProtocolValidationError(
500
+ f"The non-finite JSON number {value!r} was rejected."
501
+ )
502
+ return decoded
503
+
504
+
505
+ def decode_request_frame(frame: bytes) -> ProtocolEnvelope:
506
+ return decode_frame(
507
+ frame,
508
+ maximum_bytes=MAX_REQUEST_BYTES,
509
+ allowed_operations=_REQUEST_OPERATIONS,
510
+ )
511
+
512
+
513
+ def decode_control_frame(frame: bytes) -> ProtocolEnvelope:
514
+ return decode_frame(
515
+ frame,
516
+ maximum_bytes=MAX_CONTROL_BYTES,
517
+ allowed_operations=_CONTROL_OPERATIONS,
518
+ )
519
+
520
+
521
+ def decode_reply_frame(frame: bytes) -> ProtocolEnvelope:
522
+ return decode_frame(
523
+ frame,
524
+ maximum_bytes=MAX_REPLY_BYTES,
525
+ allowed_operations=_REPLY_OPERATIONS,
526
+ )
527
+
528
+
529
+ def _scalar_wire_size(value: object) -> int:
530
+ """Validate one scalar and conservatively size its tagged JSON form."""
531
+
532
+ if value is None:
533
+ return 8
534
+ if isinstance(value, bool):
535
+ return 16
536
+ if isinstance(value, int):
537
+ if value < _INTEGER_MIN or value > _INTEGER_MAX:
538
+ raise TrustedLiveProtocolValidationError(
539
+ "SQLite integer values must fit in a signed 64-bit integer."
540
+ )
541
+ return 16 + len(str(value))
542
+ if isinstance(value, float):
543
+ return 16 + len(value.hex())
544
+ if isinstance(value, str):
545
+ return 16 + _json_string_wire_size(
546
+ value,
547
+ "A SQLite text value",
548
+ MAX_SCALAR_BYTES,
549
+ )
550
+ if isinstance(value, memoryview):
551
+ blob_size = value.nbytes
552
+ elif isinstance(value, (bytes, bytearray)):
553
+ blob_size = len(value)
554
+ else:
555
+ raise TrustedLiveProtocolValidationError(
556
+ "Only SQLite null, integer, real, text, and blob scalars may cross IPC."
557
+ )
558
+ if blob_size > MAX_SCALAR_BYTES:
559
+ raise TrustedLiveProtocolValidationError(
560
+ f"A SQLite blob exceeds the {MAX_SCALAR_BYTES}-byte limit."
561
+ )
562
+ base64_size = 4 * ((blob_size + 2) // 3)
563
+ return 16 + base64_size
564
+
565
+
566
+ def _preflight_scalar(
567
+ value: object,
568
+ budget: _WireSizeBudget,
569
+ description: str,
570
+ ) -> None:
571
+ budget.consume(_scalar_wire_size(value), description)
572
+
573
+
574
+ def _encode_scalar(value: object) -> list[JsonValue]:
575
+ if value is None:
576
+ return ["null"]
577
+ if isinstance(value, bool):
578
+ return ["integer", int(value)]
579
+ if isinstance(value, int):
580
+ if value < _INTEGER_MIN or value > _INTEGER_MAX:
581
+ raise TrustedLiveProtocolValidationError(
582
+ "SQLite integer values must fit in a signed 64-bit integer."
583
+ )
584
+ return ["integer", value]
585
+ if isinstance(value, float):
586
+ return ["real", value.hex()]
587
+ if isinstance(value, str):
588
+ _utf8_size(value, "A SQLite text value", MAX_SCALAR_BYTES)
589
+ return ["text", value]
590
+ if isinstance(value, (bytes, bytearray, memoryview)):
591
+ blob = bytes(value)
592
+ if len(blob) > MAX_SCALAR_BYTES:
593
+ raise TrustedLiveProtocolValidationError(
594
+ f"A SQLite blob exceeds the {MAX_SCALAR_BYTES}-byte limit."
595
+ )
596
+ return ["blob", base64.b64encode(blob).decode("ascii")]
597
+ raise TrustedLiveProtocolValidationError(
598
+ "Only SQLite null, integer, real, text, and blob scalars may cross IPC."
599
+ )
600
+
601
+
602
+ def _decode_scalar(encoded: object) -> None | int | float | str | bytes:
603
+ if not isinstance(encoded, list) or not encoded or not isinstance(encoded[0], str):
604
+ raise TrustedLiveProtocolValidationError(
605
+ "A SQLite scalar has an invalid tagged representation."
606
+ )
607
+ tag = encoded[0]
608
+ if tag == "null" and len(encoded) == 1:
609
+ return None
610
+ if tag == "integer" and len(encoded) == 2:
611
+ value = encoded[1]
612
+ if type(value) is int and _INTEGER_MIN <= value <= _INTEGER_MAX:
613
+ return value
614
+ elif (
615
+ tag == "real"
616
+ and len(encoded) == 2
617
+ and isinstance(encoded[1], str)
618
+ and len(encoded[1]) <= _MAX_REAL_HEX_CHARS
619
+ ):
620
+ try:
621
+ value = float.fromhex(encoded[1])
622
+ except ValueError:
623
+ pass
624
+ else:
625
+ if value.hex() == encoded[1]:
626
+ return value
627
+ elif tag == "text" and len(encoded) == 2 and isinstance(encoded[1], str):
628
+ _utf8_size(encoded[1], "A SQLite text value", MAX_SCALAR_BYTES)
629
+ return encoded[1]
630
+ elif (
631
+ tag == "blob"
632
+ and len(encoded) == 2
633
+ and isinstance(encoded[1], str)
634
+ and len(encoded[1]) <= _MAX_BASE64_SCALAR_CHARS
635
+ ):
636
+ try:
637
+ value = base64.b64decode(encoded[1], validate=True)
638
+ except (ValueError, binascii.Error):
639
+ pass
640
+ else:
641
+ if len(value) <= MAX_SCALAR_BYTES:
642
+ return value
643
+ raise TrustedLiveProtocolValidationError(
644
+ "A SQLite scalar has an invalid or oversized tagged value."
645
+ )
646
+
647
+
648
+ def _preflight_bindings(
649
+ bindings: SqliteBindings,
650
+ budget: _WireSizeBudget,
651
+ ) -> None:
652
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query bindings")
653
+ if bindings is None:
654
+ return
655
+ if isinstance(bindings, Mapping):
656
+ if len(bindings) > MAX_BINDINGS_PER_QUERY:
657
+ raise TrustedLiveProtocolValidationError(
658
+ "A query has too many named bindings."
659
+ )
660
+ seen: set[str] = set()
661
+ for key, value in bindings.items():
662
+ if not isinstance(key, str) or not key or key in seen:
663
+ raise TrustedLiveProtocolValidationError(
664
+ "Named binding keys must be unique non-empty text."
665
+ )
666
+ key_size = _json_string_wire_size(
667
+ key,
668
+ "A named binding key",
669
+ MAX_BINDING_NAME_BYTES,
670
+ )
671
+ seen.add(key)
672
+ budget.consume(
673
+ _WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + key_size,
674
+ "Named query bindings",
675
+ )
676
+ _preflight_scalar(value, budget, "Query binding values")
677
+ return
678
+ if isinstance(bindings, (str, bytes, bytearray, memoryview)):
679
+ values: Sequence[object] = (bindings,)
680
+ elif isinstance(bindings, Sequence):
681
+ values = bindings
682
+ else:
683
+ raise TrustedLiveProtocolValidationError(
684
+ "SQLite bindings must be a sequence, mapping, scalar text/blob, or None."
685
+ )
686
+ if len(values) > MAX_BINDINGS_PER_QUERY:
687
+ raise TrustedLiveProtocolValidationError("A query has too many bindings.")
688
+ for value in values:
689
+ budget.consume(_WIRE_FIELD_BUDGET_BYTES, "Sequential query bindings")
690
+ _preflight_scalar(value, budget, "Query binding values")
691
+
692
+
693
+ def _bounded_binding_references(bindings: SqliteBindings) -> SqliteBindings:
694
+ """Materialise at most the binding-count cap without copying blob data."""
695
+
696
+ if bindings is None:
697
+ return None
698
+ if isinstance(bindings, Mapping):
699
+ try:
700
+ binding_count = len(bindings)
701
+ except Exception as error:
702
+ raise TrustedLiveProtocolValidationError(
703
+ "Named query bindings do not expose a finite size."
704
+ ) from error
705
+ if binding_count > MAX_BINDINGS_PER_QUERY:
706
+ raise TrustedLiveProtocolValidationError(
707
+ "A query has too many named bindings."
708
+ )
709
+
710
+ try:
711
+ iterator = iter(bindings.items())
712
+ except Exception as error:
713
+ raise TrustedLiveProtocolValidationError(
714
+ "Named query bindings cannot be inspected safely."
715
+ ) from error
716
+ referenced: dict[str, Any] = {}
717
+ for _ in range(binding_count):
718
+ try:
719
+ item = next(iterator)
720
+ except StopIteration as error:
721
+ raise TrustedLiveProtocolValidationError(
722
+ "Named query bindings changed while being inspected."
723
+ ) from error
724
+ except Exception as error:
725
+ raise TrustedLiveProtocolValidationError(
726
+ "Named query bindings cannot be inspected safely."
727
+ ) from error
728
+ if not isinstance(item, tuple) or len(item) != 2:
729
+ raise TrustedLiveProtocolValidationError(
730
+ "Named query bindings contain a malformed item."
731
+ )
732
+ key, value = item
733
+ if not isinstance(key, str) or not key or key in referenced:
734
+ raise TrustedLiveProtocolValidationError(
735
+ "Named binding keys must be unique non-empty text."
736
+ )
737
+ referenced[key] = value
738
+ try:
739
+ next(iterator)
740
+ except StopIteration:
741
+ pass
742
+ except Exception as error:
743
+ raise TrustedLiveProtocolValidationError(
744
+ "Named query bindings cannot be inspected safely."
745
+ ) from error
746
+ else:
747
+ raise TrustedLiveProtocolValidationError(
748
+ "Named query bindings changed while being inspected."
749
+ )
750
+ return referenced
751
+ elif isinstance(bindings, (str, bytes, bytearray, memoryview)):
752
+ # These are single SQLite scalars, never collections of characters or
753
+ # integer bytes. In particular, Stage 2 otherwise expands memoryview
754
+ # into a tuple of integers in TrustedQuery.__post_init__.
755
+ return (bindings,)
756
+ elif isinstance(bindings, Sequence):
757
+ try:
758
+ binding_count = len(bindings)
759
+ except Exception as error:
760
+ raise TrustedLiveProtocolValidationError(
761
+ "Sequential query bindings do not expose a finite size."
762
+ ) from error
763
+ if binding_count > MAX_BINDINGS_PER_QUERY:
764
+ raise TrustedLiveProtocolValidationError("A query has too many bindings.")
765
+ referenced_values: list[Any] = []
766
+ for index in range(binding_count):
767
+ try:
768
+ referenced_values.append(bindings[index])
769
+ except Exception as error:
770
+ raise TrustedLiveProtocolValidationError(
771
+ "Sequential query bindings changed while being inspected."
772
+ ) from error
773
+ return tuple(referenced_values)
774
+ raise TrustedLiveProtocolValidationError(
775
+ "SQLite bindings must be a sequence, mapping, scalar text/blob, or None."
776
+ )
777
+
778
+
779
+ def _freeze_binding_scalar(
780
+ value: Any,
781
+ budget: _WireSizeBudget,
782
+ ) -> Any:
783
+ """Charge and snapshot one scalar without an unbounded mutable-blob copy."""
784
+
785
+ if isinstance(value, bytearray):
786
+ view: memoryview | None = None
787
+ try:
788
+ # Exporting a view prevents another thread from resizing the
789
+ # bytearray between sizing and copying it.
790
+ view = memoryview(value)
791
+ _preflight_scalar(view, budget, "Query binding values")
792
+ return view.tobytes()
793
+ except TrustedLiveProtocolValidationError:
794
+ raise
795
+ except (BufferError, TypeError, ValueError) as error:
796
+ raise TrustedLiveProtocolValidationError(
797
+ "A mutable SQLite blob binding could not be snapshotted safely."
798
+ ) from error
799
+ finally:
800
+ if view is not None:
801
+ view.release()
802
+ if isinstance(value, memoryview):
803
+ try:
804
+ _preflight_scalar(value, budget, "Query binding values")
805
+ return value.tobytes()
806
+ except TrustedLiveProtocolValidationError:
807
+ raise
808
+ except (BufferError, TypeError, ValueError) as error:
809
+ raise TrustedLiveProtocolValidationError(
810
+ "A SQLite blob view could not be snapshotted safely."
811
+ ) from error
812
+ _preflight_scalar(value, budget, "Query binding values")
813
+ return value
814
+
815
+
816
+ def _freeze_bindings_with_budget(
817
+ bindings: SqliteBindings,
818
+ budget: _WireSizeBudget,
819
+ ) -> SqliteBindings:
820
+ """Validate and freeze bounded binding references into a shared budget."""
821
+
822
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query bindings")
823
+ if bindings is None:
824
+ return None
825
+ if isinstance(bindings, Mapping):
826
+ frozen_mapping: dict[str, Any] = {}
827
+ for key, value in bindings.items():
828
+ key_size = _json_string_wire_size(
829
+ key,
830
+ "A named binding key",
831
+ MAX_BINDING_NAME_BYTES,
832
+ )
833
+ budget.consume(
834
+ _WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + key_size,
835
+ "Named query bindings",
836
+ )
837
+ frozen_mapping[key] = _freeze_binding_scalar(value, budget)
838
+ return frozen_mapping
839
+ frozen_values: list[Any] = []
840
+ for value in bindings:
841
+ budget.consume(_WIRE_FIELD_BUDGET_BYTES, "Sequential query bindings")
842
+ frozen_values.append(_freeze_binding_scalar(value, budget))
843
+ return tuple(frozen_values)
844
+
845
+
846
+ def _normalize_query_with_budget(
847
+ sql: object,
848
+ bindings: SqliteBindings,
849
+ budget: _WireSizeBudget,
850
+ ) -> TrustedQuery:
851
+ if not isinstance(sql, str):
852
+ raise TrustedLiveProtocolValidationError("Query SQL must be text.")
853
+ sql_size = _json_string_wire_size(sql, "Query SQL", MAX_SQL_BYTES)
854
+ budget.consume(
855
+ _WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + sql_size,
856
+ "Query specifications",
857
+ )
858
+ bounded = _bounded_binding_references(bindings)
859
+ frozen = _freeze_bindings_with_budget(bounded, budget)
860
+ return TrustedQuery(sql, frozen)
861
+
862
+
863
+ def normalize_query_specification(
864
+ sql: object,
865
+ bindings: SqliteBindings,
866
+ ) -> TrustedQuery:
867
+ """Build one bounded query before ``TrustedQuery`` can copy raw input."""
868
+
869
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
870
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
871
+ return _normalize_query_with_budget(sql, bindings, budget)
872
+
873
+
874
+ def normalize_query_batch(
875
+ queries: Sequence[TrustedQuery],
876
+ ) -> tuple[TrustedQuery, ...]:
877
+ """Snapshot one finite batch within a single aggregate request budget."""
878
+
879
+ if not isinstance(queries, Sequence) or isinstance(
880
+ queries, (str, bytes, bytearray, memoryview)
881
+ ):
882
+ raise TrustedLiveProtocolValidationError(
883
+ "Query specifications must be a bounded sequence."
884
+ )
885
+ try:
886
+ query_count = len(queries)
887
+ except Exception as error:
888
+ raise TrustedLiveProtocolValidationError(
889
+ "Query specifications do not expose a finite size."
890
+ ) from error
891
+ if not 1 <= query_count <= MAX_BATCH_QUERIES:
892
+ raise TrustedLiveProtocolValidationError(
893
+ f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
894
+ )
895
+ specifications: list[TrustedQuery] = []
896
+ for index in range(query_count):
897
+ try:
898
+ query = queries[index]
899
+ except Exception as error:
900
+ raise TrustedLiveProtocolValidationError(
901
+ "Query specifications changed while being inspected."
902
+ ) from error
903
+ if not isinstance(query, TrustedQuery):
904
+ raise TrustedLiveProtocolValidationError(
905
+ "Query batches accept only TrustedQuery specifications."
906
+ )
907
+ specifications.append(query)
908
+
909
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
910
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
911
+ return tuple(
912
+ _normalize_query_with_budget(query.sql, query.bindings, budget)
913
+ for query in specifications
914
+ )
915
+
916
+
917
+ def _encode_bindings_prevalidated(bindings: SqliteBindings) -> dict[str, JsonValue]:
918
+ if bindings is None:
919
+ return {"kind": "none"}
920
+ if isinstance(bindings, Mapping):
921
+ return {
922
+ "kind": "mapping",
923
+ "items": [[key, _encode_scalar(value)] for key, value in bindings.items()],
924
+ }
925
+ if isinstance(bindings, (str, bytes, bytearray, memoryview)):
926
+ values: Sequence[object] = (bindings,)
927
+ else:
928
+ values = bindings
929
+ return {"kind": "sequence", "values": [_encode_scalar(value) for value in values]}
930
+
931
+
932
+ def encode_bindings(bindings: SqliteBindings) -> dict[str, JsonValue]:
933
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
934
+ _preflight_bindings(bindings, budget)
935
+ return _encode_bindings_prevalidated(bindings)
936
+
937
+
938
+ def decode_bindings(encoded: object) -> SqliteBindings:
939
+ if not isinstance(encoded, dict) or "kind" not in encoded:
940
+ raise TrustedLiveProtocolValidationError("Query bindings are malformed.")
941
+ kind = encoded["kind"]
942
+ if kind == "none" and set(encoded) == {"kind"}:
943
+ return None
944
+ if kind == "sequence" and set(encoded) == {"kind", "values"}:
945
+ values = encoded["values"]
946
+ if not isinstance(values, list) or len(values) > MAX_BINDINGS_PER_QUERY:
947
+ raise TrustedLiveProtocolValidationError(
948
+ "Sequential query bindings are malformed or oversized."
949
+ )
950
+ return tuple(_decode_scalar(value) for value in values)
951
+ if kind == "mapping" and set(encoded) == {"kind", "items"}:
952
+ items = encoded["items"]
953
+ if not isinstance(items, list) or len(items) > MAX_BINDINGS_PER_QUERY:
954
+ raise TrustedLiveProtocolValidationError(
955
+ "Named query bindings are malformed or oversized."
956
+ )
957
+ result: dict[str, object] = {}
958
+ for item in items:
959
+ if (
960
+ not isinstance(item, list)
961
+ or len(item) != 2
962
+ or not isinstance(item[0], str)
963
+ or not item[0]
964
+ or item[0] in result
965
+ ):
966
+ raise TrustedLiveProtocolValidationError(
967
+ "Named query bindings contain an invalid or duplicate key."
968
+ )
969
+ _utf8_size(item[0], "A named binding key", MAX_BINDING_NAME_BYTES)
970
+ result[item[0]] = _decode_scalar(item[1])
971
+ return result
972
+ raise TrustedLiveProtocolValidationError("Query bindings are malformed.")
973
+
974
+
975
+ def _preflight_queries(
976
+ specifications: tuple[TrustedQuery, ...],
977
+ budget: _WireSizeBudget,
978
+ ) -> None:
979
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
980
+ if not specifications or len(specifications) > MAX_BATCH_QUERIES:
981
+ raise TrustedLiveProtocolValidationError(
982
+ f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
983
+ )
984
+ for query in specifications:
985
+ if not isinstance(query, TrustedQuery):
986
+ raise TrustedLiveProtocolValidationError(
987
+ "Query batches accept only TrustedQuery specifications."
988
+ )
989
+ if not isinstance(query.sql, str):
990
+ raise TrustedLiveProtocolValidationError("Query SQL must be text.")
991
+ sql_size = _json_string_wire_size(query.sql, "Query SQL", MAX_SQL_BYTES)
992
+ budget.consume(
993
+ _WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + sql_size,
994
+ "Query specifications",
995
+ )
996
+ _preflight_bindings(query.bindings, budget)
997
+
998
+
999
+ def encode_queries(queries: Sequence[TrustedQuery]) -> list[JsonValue]:
1000
+ if not isinstance(queries, Sequence) or isinstance(
1001
+ queries, (str, bytes, bytearray, memoryview)
1002
+ ):
1003
+ raise TrustedLiveProtocolValidationError(
1004
+ "Query specifications must be a bounded sequence."
1005
+ )
1006
+ query_count = len(queries)
1007
+ if not 1 <= query_count <= MAX_BATCH_QUERIES:
1008
+ raise TrustedLiveProtocolValidationError(
1009
+ f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
1010
+ )
1011
+ specifications = tuple(queries[index] for index in range(query_count))
1012
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
1013
+ _preflight_queries(specifications, budget)
1014
+ return [
1015
+ {
1016
+ "bindings": _encode_bindings_prevalidated(query.bindings),
1017
+ "sql": query.sql,
1018
+ }
1019
+ for query in specifications
1020
+ ]
1021
+
1022
+
1023
+ def decode_queries(encoded: object) -> tuple[TrustedQuery, ...]:
1024
+ if not isinstance(encoded, list) or not encoded or len(encoded) > MAX_BATCH_QUERIES:
1025
+ raise TrustedLiveProtocolValidationError(
1026
+ "The query list is empty, malformed, or oversized."
1027
+ )
1028
+ queries: list[TrustedQuery] = []
1029
+ for item in encoded:
1030
+ if not isinstance(item, dict) or set(item) != {"sql", "bindings"}:
1031
+ raise TrustedLiveProtocolValidationError(
1032
+ "A query specification has invalid fields."
1033
+ )
1034
+ sql = item["sql"]
1035
+ if not isinstance(sql, str):
1036
+ raise TrustedLiveProtocolValidationError("Query SQL must be text.")
1037
+ _utf8_size(sql, "Query SQL", MAX_SQL_BYTES)
1038
+ queries.append(TrustedQuery(sql, decode_bindings(item["bindings"])))
1039
+ return tuple(queries)
1040
+
1041
+
1042
+ def validate_timeout_ms(value: object) -> int:
1043
+ if (
1044
+ type(value) is not int
1045
+ or value < MIN_OPERATION_TIMEOUT_MS
1046
+ or value > MAX_OPERATION_TIMEOUT_MS
1047
+ ):
1048
+ raise TrustedLiveProtocolValidationError(
1049
+ "The child operation timeout is outside the bounded protocol range."
1050
+ )
1051
+ return value
1052
+
1053
+
1054
+ def encode_job_request(
1055
+ session: str,
1056
+ generation: int,
1057
+ operation: str,
1058
+ queries: Sequence[TrustedQuery] | None,
1059
+ timeout_ms: int,
1060
+ ) -> bytes:
1061
+ validate_generation(generation, allow_zero=False)
1062
+ validate_timeout_ms(timeout_ms)
1063
+ if operation == "query":
1064
+ encoded_queries = encode_queries(queries or ())
1065
+ if len(encoded_queries) != 1:
1066
+ raise TrustedLiveProtocolValidationError(
1067
+ "A query job must contain exactly one statement."
1068
+ )
1069
+ payload: dict[str, Any] = {
1070
+ "queries": encoded_queries,
1071
+ "timeout_ms": timeout_ms,
1072
+ }
1073
+ elif operation == "query_batch":
1074
+ payload = {
1075
+ "queries": encode_queries(queries or ()),
1076
+ "timeout_ms": timeout_ms,
1077
+ }
1078
+ elif operation == "data_version" and queries is None:
1079
+ payload = {"timeout_ms": timeout_ms}
1080
+ else:
1081
+ raise TrustedLiveProtocolValidationError(
1082
+ "The requested job operation is invalid."
1083
+ )
1084
+ return encode_frame(
1085
+ session,
1086
+ generation,
1087
+ operation,
1088
+ payload,
1089
+ maximum_bytes=MAX_REQUEST_BYTES,
1090
+ )
1091
+
1092
+
1093
+ def decode_job_request(
1094
+ envelope: ProtocolEnvelope,
1095
+ ) -> tuple[tuple[TrustedQuery, ...] | None, int]:
1096
+ payload = envelope.payload
1097
+ if envelope.operation in {"query", "query_batch"}:
1098
+ if set(payload) != {"queries", "timeout_ms"}:
1099
+ raise TrustedLiveProtocolValidationError(
1100
+ "The query request has invalid fields."
1101
+ )
1102
+ queries = decode_queries(payload["queries"])
1103
+ if envelope.operation == "query" and len(queries) != 1:
1104
+ raise TrustedLiveProtocolValidationError(
1105
+ "A query request must contain exactly one statement."
1106
+ )
1107
+ return queries, validate_timeout_ms(payload["timeout_ms"])
1108
+ if envelope.operation == "data_version":
1109
+ if set(payload) != {"timeout_ms"}:
1110
+ raise TrustedLiveProtocolValidationError(
1111
+ "The data-version request has invalid fields."
1112
+ )
1113
+ return None, validate_timeout_ms(payload["timeout_ms"])
1114
+ raise TrustedLiveProtocolValidationError("The envelope is not a database job.")
1115
+
1116
+
1117
+ def encode_cancel(session: str, generation: int) -> bytes:
1118
+ validate_generation(generation, allow_zero=False)
1119
+ return encode_frame(
1120
+ session,
1121
+ generation,
1122
+ "cancel",
1123
+ {},
1124
+ maximum_bytes=MAX_CONTROL_BYTES,
1125
+ )
1126
+
1127
+
1128
+ def validate_cancel(envelope: ProtocolEnvelope) -> None:
1129
+ if envelope.operation != "cancel" or envelope.payload:
1130
+ raise TrustedLiveProtocolValidationError(
1131
+ "A cancellation frame has invalid fields."
1132
+ )
1133
+ validate_generation(envelope.generation, allow_zero=False)
1134
+
1135
+
1136
+ def encode_shutdown(session: str, generation: int) -> bytes:
1137
+ validate_generation(generation, allow_zero=False)
1138
+ return encode_frame(
1139
+ session,
1140
+ generation,
1141
+ "shutdown",
1142
+ {},
1143
+ maximum_bytes=MAX_REQUEST_BYTES,
1144
+ )
1145
+
1146
+
1147
+ def validate_shutdown(envelope: ProtocolEnvelope) -> None:
1148
+ if envelope.operation != "shutdown" or envelope.payload:
1149
+ raise TrustedLiveProtocolValidationError("A shutdown frame has invalid fields.")
1150
+
1151
+
1152
+ def _identity_field_wire_sizes(identity: object) -> tuple[int, ...]:
1153
+ if not isinstance(identity, tuple):
1154
+ raise TrustedLiveProtocolValidationError("A database file identity is invalid.")
1155
+ valid_shape = (
1156
+ len(identity) == 2 and type(identity[0]) is int and type(identity[1]) is int
1157
+ ) or (
1158
+ len(identity) == 3
1159
+ and type(identity[0]) is str
1160
+ and type(identity[2]) is int
1161
+ and type(identity[1]) in {str, int}
1162
+ )
1163
+ if not valid_shape:
1164
+ raise TrustedLiveProtocolValidationError(
1165
+ "A database file identity has an unsupported shape."
1166
+ )
1167
+ sizes: list[int] = []
1168
+ for index, value in enumerate(identity):
1169
+ if type(value) is str:
1170
+ maximum = (
1171
+ MAX_PATH_BYTES
1172
+ if len(identity) == 3 and identity[0] == "birthtime" and index == 1
1173
+ else 1_024
1174
+ )
1175
+ sizes.append(
1176
+ _json_string_wire_size(
1177
+ value,
1178
+ "A database identity field",
1179
+ maximum,
1180
+ )
1181
+ )
1182
+ elif type(value) is int and 0 <= value <= _IDENTITY_INTEGER_MAX:
1183
+ sizes.append(len(str(value)))
1184
+ else:
1185
+ raise TrustedLiveProtocolValidationError(
1186
+ "Database identity integers must be non-negative unsigned "
1187
+ "64-bit values."
1188
+ )
1189
+ return tuple(sizes)
1190
+
1191
+
1192
+ def _encode_identity_prevalidated(
1193
+ identity: DatabaseFileIdentity | None,
1194
+ ) -> JsonValue:
1195
+ return None if identity is None else list(identity)
1196
+
1197
+
1198
+ def _preflight_identity(
1199
+ identity: DatabaseFileIdentity | None,
1200
+ budget: _WireSizeBudget,
1201
+ description: str,
1202
+ ) -> None:
1203
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, description)
1204
+ if identity is None:
1205
+ return
1206
+ for value_size in _identity_field_wire_sizes(identity):
1207
+ budget.consume(
1208
+ _WIRE_FIELD_BUDGET_BYTES + value_size,
1209
+ description,
1210
+ )
1211
+
1212
+
1213
+ def _decode_identity(encoded: object) -> DatabaseFileIdentity | None:
1214
+ if encoded is None:
1215
+ return None
1216
+ if not isinstance(encoded, list) or len(encoded) not in {2, 3}:
1217
+ raise TrustedLiveProtocolValidationError("A database file identity is invalid.")
1218
+ identity = tuple(encoded)
1219
+ _identity_field_wire_sizes(identity)
1220
+ if len(identity) == 2 and all(type(value) is int for value in identity):
1221
+ return (identity[0], identity[1])
1222
+ if (
1223
+ len(identity) == 3
1224
+ and isinstance(identity[0], str)
1225
+ and type(identity[1]) is int
1226
+ and type(identity[2]) is int
1227
+ ):
1228
+ return (identity[0], identity[1], identity[2])
1229
+ if (
1230
+ len(identity) == 3
1231
+ and isinstance(identity[0], str)
1232
+ and isinstance(identity[1], str)
1233
+ and type(identity[2]) is int
1234
+ ):
1235
+ return (identity[0], identity[1], identity[2])
1236
+ raise TrustedLiveProtocolValidationError(
1237
+ "A database file identity has an unsupported shape."
1238
+ )
1239
+
1240
+
1241
+ def _validate_database_instance_path(value: object, description: str) -> str:
1242
+ if type(value) is not str or not value or "\x00" in value:
1243
+ raise TrustedLiveProtocolValidationError(
1244
+ f"{description} must be non-empty text without NUL bytes."
1245
+ )
1246
+ _utf8_size(value, description, MAX_PATH_BYTES)
1247
+ return value
1248
+
1249
+
1250
+ def _preflight_database_instance(
1251
+ instance: DatabaseInstance | None,
1252
+ budget: _WireSizeBudget,
1253
+ ) -> None:
1254
+ budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Database instance")
1255
+ if instance is None:
1256
+ return
1257
+ if not isinstance(instance, DatabaseInstance):
1258
+ raise TrustedLiveProtocolValidationError(
1259
+ "The expected database instance has an invalid type."
1260
+ )
1261
+ logical_path = _validate_database_instance_path(
1262
+ instance.logical_path,
1263
+ "The logical database path",
1264
+ )
1265
+ resolved_path = _validate_database_instance_path(
1266
+ instance.resolved_path,
1267
+ "The resolved database path",
1268
+ )
1269
+ logical_path_size = _json_string_wire_size(
1270
+ logical_path,
1271
+ "The logical database path",
1272
+ MAX_PATH_BYTES,
1273
+ )
1274
+ resolved_path_size = _json_string_wire_size(
1275
+ resolved_path,
1276
+ "The resolved database path",
1277
+ MAX_PATH_BYTES,
1278
+ )
1279
+ budget.consume(
1280
+ (2 * _WIRE_FIELD_BUDGET_BYTES) + logical_path_size + resolved_path_size,
1281
+ "Database instance paths",
1282
+ )
1283
+ if len(instance.sidecar_identities) > MAX_DATABASE_SIDECAR_IDENTITIES:
1284
+ raise TrustedLiveProtocolValidationError(
1285
+ "A database instance has too many sidecar identities."
1286
+ )
1287
+ _preflight_identity(instance.identity, budget, "Database main-file identity")
1288
+ for identity in instance.sidecar_identities:
1289
+ _preflight_identity(identity, budget, "Database sidecar identities")
1290
+
1291
+
1292
+ def encode_database_instance(instance: DatabaseInstance | None) -> JsonValue:
1293
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
1294
+ _preflight_database_instance(instance, budget)
1295
+ if instance is None:
1296
+ return None
1297
+ sidecars = [
1298
+ _encode_identity_prevalidated(identity)
1299
+ for identity in instance.sidecar_identities
1300
+ ]
1301
+ sidecars.sort(key=lambda value: json.dumps(value, separators=(",", ":")))
1302
+ return {
1303
+ "identity": _encode_identity_prevalidated(instance.identity),
1304
+ "logical_path": instance.logical_path,
1305
+ "resolved_path": instance.resolved_path,
1306
+ "sidecar_identities": sidecars,
1307
+ }
1308
+
1309
+
1310
+ def decode_database_instance(encoded: object) -> DatabaseInstance | None:
1311
+ if encoded is None:
1312
+ return None
1313
+ if not isinstance(encoded, dict) or set(encoded) != {
1314
+ "identity",
1315
+ "logical_path",
1316
+ "resolved_path",
1317
+ "sidecar_identities",
1318
+ }:
1319
+ raise TrustedLiveProtocolValidationError(
1320
+ "A database instance has invalid fields."
1321
+ )
1322
+ logical_path = encoded["logical_path"]
1323
+ resolved_path = encoded["resolved_path"]
1324
+ sidecars = encoded["sidecar_identities"]
1325
+ logical_path = _validate_database_instance_path(
1326
+ logical_path,
1327
+ "The logical database path",
1328
+ )
1329
+ resolved_path = _validate_database_instance_path(
1330
+ resolved_path,
1331
+ "The resolved database path",
1332
+ )
1333
+ if (
1334
+ not isinstance(sidecars, list)
1335
+ or len(sidecars) > MAX_DATABASE_SIDECAR_IDENTITIES
1336
+ ):
1337
+ raise TrustedLiveProtocolValidationError(
1338
+ "Database sidecar identities are malformed or oversized."
1339
+ )
1340
+ decoded_sidecars = [_decode_identity(identity) for identity in sidecars]
1341
+ if any(identity is None for identity in decoded_sidecars):
1342
+ raise TrustedLiveProtocolValidationError(
1343
+ "A sidecar identity must not be absent inside the identity list."
1344
+ )
1345
+ sidecar_set = frozenset(decoded_sidecars)
1346
+ if len(sidecar_set) != len(decoded_sidecars):
1347
+ raise TrustedLiveProtocolValidationError(
1348
+ "Database sidecar identities must be unique."
1349
+ )
1350
+ return DatabaseInstance(
1351
+ logical_path=logical_path,
1352
+ resolved_path=resolved_path,
1353
+ identity=_decode_identity(encoded["identity"]),
1354
+ sidecar_identities=sidecar_set, # type: ignore[arg-type]
1355
+ )
1356
+
1357
+
1358
+ def _validate_startup_database_path(value: object) -> str:
1359
+ if type(value) is not str or not value or "\x00" in value:
1360
+ raise TrustedLiveProtocolValidationError(
1361
+ "The startup database path must be non-empty text without NUL bytes."
1362
+ )
1363
+ _utf8_size(value, "The startup database path", MAX_PATH_BYTES)
1364
+ return value
1365
+
1366
+
1367
+ def _validate_startup_integer(
1368
+ value: object,
1369
+ *,
1370
+ minimum: int,
1371
+ maximum: int,
1372
+ description: str,
1373
+ ) -> int:
1374
+ if type(value) is not int or value < minimum or value > maximum:
1375
+ raise TrustedLiveProtocolValidationError(
1376
+ f"{description} must be an integer from {minimum} through {maximum}."
1377
+ )
1378
+ return value
1379
+
1380
+
1381
+ def encode_startup_request(
1382
+ session: str,
1383
+ first_generation: int,
1384
+ database_path: str,
1385
+ expected_database_instance: DatabaseInstance | None,
1386
+ busy_timeout_ms: int,
1387
+ operation_timeout_ms: int,
1388
+ orphan_grace_ms: int,
1389
+ ) -> bytes:
1390
+ """Encode all application startup configuration in one bounded frame."""
1391
+
1392
+ validate_generation(first_generation, allow_zero=False)
1393
+ validated_path = _validate_startup_database_path(database_path)
1394
+ validated_busy_timeout = _validate_startup_integer(
1395
+ busy_timeout_ms,
1396
+ minimum=0,
1397
+ maximum=_MAX_BUSY_TIMEOUT_MS,
1398
+ description="The startup busy timeout",
1399
+ )
1400
+ validated_operation_timeout = _validate_startup_integer(
1401
+ operation_timeout_ms,
1402
+ minimum=_MIN_STARTUP_TIMEOUT_MS,
1403
+ maximum=MAX_OPERATION_TIMEOUT_MS,
1404
+ description="The startup operation timeout",
1405
+ )
1406
+ validated_orphan_grace = _validate_startup_integer(
1407
+ orphan_grace_ms,
1408
+ minimum=_MIN_ORPHAN_GRACE_MS,
1409
+ maximum=MAX_OPERATION_TIMEOUT_MS,
1410
+ description="The startup orphan grace period",
1411
+ )
1412
+ budget = _new_role_budget(MAX_REQUEST_BYTES)
1413
+ budget.consume(
1414
+ _WIRE_CONTAINER_BUDGET_BYTES
1415
+ + (6 * _WIRE_FIELD_BUDGET_BYTES)
1416
+ + _json_string_wire_size(
1417
+ validated_path,
1418
+ "The startup database path",
1419
+ MAX_PATH_BYTES,
1420
+ ),
1421
+ "The startup request",
1422
+ )
1423
+ _preflight_database_instance(expected_database_instance, budget)
1424
+ payload = {
1425
+ "busy_timeout_ms": validated_busy_timeout,
1426
+ "database_path": validated_path,
1427
+ "expected_database_instance": encode_database_instance(
1428
+ expected_database_instance
1429
+ ),
1430
+ "first_generation": first_generation,
1431
+ "operation_timeout_ms": validated_operation_timeout,
1432
+ "orphan_grace_ms": validated_orphan_grace,
1433
+ }
1434
+ return encode_frame(
1435
+ session,
1436
+ 0,
1437
+ "startup",
1438
+ payload,
1439
+ maximum_bytes=MAX_REQUEST_BYTES,
1440
+ )
1441
+
1442
+
1443
+ def decode_startup_request(
1444
+ envelope: ProtocolEnvelope,
1445
+ ) -> tuple[int, str, DatabaseInstance | None, int, int, int]:
1446
+ """Validate and decode one generation-zero startup request."""
1447
+
1448
+ if envelope.operation != "startup" or envelope.generation != 0:
1449
+ raise TrustedLiveProtocolValidationError(
1450
+ "The startup request must use operation 'startup' and generation zero."
1451
+ )
1452
+ payload = envelope.payload
1453
+ if set(payload) != {
1454
+ "first_generation",
1455
+ "database_path",
1456
+ "expected_database_instance",
1457
+ "busy_timeout_ms",
1458
+ "operation_timeout_ms",
1459
+ "orphan_grace_ms",
1460
+ }:
1461
+ raise TrustedLiveProtocolValidationError(
1462
+ "The startup request has invalid fields."
1463
+ )
1464
+ first_generation = validate_generation(
1465
+ payload["first_generation"],
1466
+ allow_zero=False,
1467
+ )
1468
+ database_path = _validate_startup_database_path(payload["database_path"])
1469
+ expected_instance = decode_database_instance(payload["expected_database_instance"])
1470
+ busy_timeout_ms = _validate_startup_integer(
1471
+ payload["busy_timeout_ms"],
1472
+ minimum=0,
1473
+ maximum=_MAX_BUSY_TIMEOUT_MS,
1474
+ description="The startup busy timeout",
1475
+ )
1476
+ operation_timeout_ms = _validate_startup_integer(
1477
+ payload["operation_timeout_ms"],
1478
+ minimum=_MIN_STARTUP_TIMEOUT_MS,
1479
+ maximum=MAX_OPERATION_TIMEOUT_MS,
1480
+ description="The startup operation timeout",
1481
+ )
1482
+ orphan_grace_ms = _validate_startup_integer(
1483
+ payload["orphan_grace_ms"],
1484
+ minimum=_MIN_ORPHAN_GRACE_MS,
1485
+ maximum=MAX_OPERATION_TIMEOUT_MS,
1486
+ description="The startup orphan grace period",
1487
+ )
1488
+ return (
1489
+ first_generation,
1490
+ database_path,
1491
+ expected_instance,
1492
+ busy_timeout_ms,
1493
+ operation_timeout_ms,
1494
+ orphan_grace_ms,
1495
+ )
1496
+
1497
+
1498
+ def encode_source_identity(source: TrustedLiveSourceIdentity) -> dict[str, JsonValue]:
1499
+ if not isinstance(source, TrustedLiveSourceIdentity):
1500
+ raise TrustedLiveProtocolValidationError("The source identity is invalid.")
1501
+ if source.journal_mode not in {"wal", "rollback"}:
1502
+ raise TrustedLiveProtocolValidationError(
1503
+ "The source journal mode is unsupported."
1504
+ )
1505
+ budget = _new_role_budget(MAX_REPLY_BYTES)
1506
+ _preflight_database_instance(source.database_instance, budget)
1507
+ _preflight_identity(source.journal_identity, budget, "Journal identity")
1508
+ _preflight_identity(source.shm_identity, budget, "SHM identity")
1509
+ _preflight_identity(source.wal_identity, budget, "WAL identity")
1510
+ return {
1511
+ "database_instance": encode_database_instance(source.database_instance),
1512
+ "journal_identity": _encode_identity_prevalidated(source.journal_identity),
1513
+ "journal_mode": source.journal_mode,
1514
+ "shm_identity": _encode_identity_prevalidated(source.shm_identity),
1515
+ "wal_identity": _encode_identity_prevalidated(source.wal_identity),
1516
+ }
1517
+
1518
+
1519
+ def decode_source_identity(encoded: object) -> TrustedLiveSourceIdentity:
1520
+ if not isinstance(encoded, dict) or set(encoded) != {
1521
+ "database_instance",
1522
+ "wal_identity",
1523
+ "shm_identity",
1524
+ "journal_identity",
1525
+ "journal_mode",
1526
+ }:
1527
+ raise TrustedLiveProtocolValidationError(
1528
+ "The trusted source identity has invalid fields."
1529
+ )
1530
+ instance = decode_database_instance(encoded["database_instance"])
1531
+ if instance is None or encoded["journal_mode"] not in {"wal", "rollback"}:
1532
+ raise TrustedLiveProtocolValidationError(
1533
+ "The trusted source identity is incomplete."
1534
+ )
1535
+ return TrustedLiveSourceIdentity(
1536
+ database_instance=instance,
1537
+ wal_identity=_decode_identity(encoded["wal_identity"]),
1538
+ shm_identity=_decode_identity(encoded["shm_identity"]),
1539
+ journal_identity=_decode_identity(encoded["journal_identity"]),
1540
+ journal_mode=encoded["journal_mode"],
1541
+ )
1542
+
1543
+
1544
+ def _preflight_query_results(
1545
+ materialised: tuple[TrustedQueryResult, ...],
1546
+ ) -> None:
1547
+ try:
1548
+ preflight_trusted_query_results(materialised)
1549
+ except TrustedLiveReaderError as error:
1550
+ raise TrustedLiveProtocolValidationError(str(error)) from error
1551
+
1552
+
1553
+ def encode_query_results(results: Sequence[TrustedQueryResult]) -> list[JsonValue]:
1554
+ if not isinstance(results, Sequence) or isinstance(
1555
+ results, (str, bytes, bytearray, memoryview)
1556
+ ):
1557
+ raise TrustedLiveProtocolValidationError(
1558
+ "Query results must be a bounded sequence."
1559
+ )
1560
+ result_count = len(results)
1561
+ if not 1 <= result_count <= MAX_BATCH_QUERIES:
1562
+ raise TrustedLiveProtocolValidationError(
1563
+ "The helper returned an empty or oversized result batch."
1564
+ )
1565
+ materialised = tuple(results[index] for index in range(result_count))
1566
+ _preflight_query_results(materialised)
1567
+ encoded_results: list[JsonValue] = []
1568
+ for result in materialised:
1569
+ columns: list[JsonValue] = list(result.columns)
1570
+ rows: list[JsonValue] = []
1571
+ for row in result.rows:
1572
+ rows.append([_encode_scalar(value) for value in row])
1573
+ encoded_results.append({"columns": columns, "rows": rows})
1574
+ return encoded_results
1575
+
1576
+
1577
+ def decode_query_results(encoded: object) -> tuple[TrustedQueryResult, ...]:
1578
+ if not isinstance(encoded, list) or not encoded or len(encoded) > MAX_BATCH_QUERIES:
1579
+ raise TrustedLiveProtocolValidationError(
1580
+ "The result batch is empty, malformed, or oversized."
1581
+ )
1582
+ results: list[TrustedQueryResult] = []
1583
+ total_cells = 0
1584
+ for item in encoded:
1585
+ if not isinstance(item, dict) or set(item) != {"columns", "rows"}:
1586
+ raise TrustedLiveProtocolValidationError("A result has invalid fields.")
1587
+ columns = item["columns"]
1588
+ rows = item["rows"]
1589
+ if (
1590
+ not isinstance(columns, list)
1591
+ or len(columns) > MAX_COLUMNS_PER_RESULT
1592
+ or not all(isinstance(column, str) for column in columns)
1593
+ ):
1594
+ raise TrustedLiveProtocolValidationError(
1595
+ "Result columns are malformed or oversized."
1596
+ )
1597
+ for column in columns:
1598
+ _utf8_size(column, "A result column name", MAX_BINDING_NAME_BYTES)
1599
+ if not isinstance(rows, list) or len(rows) > MAX_ROWS_PER_RESULT:
1600
+ raise TrustedLiveProtocolValidationError(
1601
+ "Result rows are malformed or oversized."
1602
+ )
1603
+ decoded_rows: list[tuple[object, ...]] = []
1604
+ for row in rows:
1605
+ if not isinstance(row, list) or len(row) != len(columns):
1606
+ raise TrustedLiveProtocolValidationError(
1607
+ "A result row has the wrong shape."
1608
+ )
1609
+ total_cells += len(row)
1610
+ if total_cells > MAX_CELLS_PER_REPLY:
1611
+ raise TrustedLiveProtocolValidationError(
1612
+ "The reply contains too many SQLite cells."
1613
+ )
1614
+ decoded_rows.append(tuple(_decode_scalar(value) for value in row))
1615
+ results.append(TrustedQueryResult(tuple(columns), tuple(decoded_rows)))
1616
+ return tuple(results)
1617
+
1618
+
1619
+ def encode_success_reply(
1620
+ session: str,
1621
+ generation: int,
1622
+ operation: str,
1623
+ payload: Mapping[str, Any] | None = None,
1624
+ ) -> bytes:
1625
+ body: dict[str, Any] = {"status": "ok"}
1626
+ if payload:
1627
+ body.update(payload)
1628
+ return encode_frame(
1629
+ session,
1630
+ generation,
1631
+ operation,
1632
+ body,
1633
+ maximum_bytes=MAX_REPLY_BYTES,
1634
+ )
1635
+
1636
+
1637
+ def encode_error_reply(
1638
+ session: str,
1639
+ generation: int,
1640
+ operation: str,
1641
+ code: str,
1642
+ message: str,
1643
+ ) -> bytes:
1644
+ if code not in _ERROR_CODES:
1645
+ raise TrustedLiveProtocolValidationError("The helper error code is unknown.")
1646
+ if not isinstance(message, str):
1647
+ message = "The trusted helper failed without a valid error message."
1648
+ encoded_message = message.encode("utf-8", errors="replace")
1649
+ if len(encoded_message) > MAX_ERROR_MESSAGE_BYTES:
1650
+ encoded_message = encoded_message[:MAX_ERROR_MESSAGE_BYTES]
1651
+ while True:
1652
+ try:
1653
+ message = encoded_message.decode("utf-8")
1654
+ break
1655
+ except UnicodeDecodeError as error:
1656
+ encoded_message = encoded_message[: error.start]
1657
+ message += "…"
1658
+ return encode_frame(
1659
+ session,
1660
+ generation,
1661
+ operation,
1662
+ {"status": "error", "code": code, "message": message},
1663
+ maximum_bytes=MAX_REPLY_BYTES,
1664
+ )
1665
+
1666
+
1667
+ def decode_reply_payload(envelope: ProtocolEnvelope) -> tuple[str, dict[str, Any]]:
1668
+ payload = envelope.payload
1669
+ status = payload.get("status")
1670
+ if status == "error":
1671
+ if set(payload) != {"status", "code", "message"}:
1672
+ raise TrustedLiveProtocolValidationError(
1673
+ "An error reply has invalid fields."
1674
+ )
1675
+ code = payload["code"]
1676
+ message = payload["message"]
1677
+ if code not in _ERROR_CODES or not isinstance(message, str):
1678
+ raise TrustedLiveProtocolValidationError(
1679
+ "An error reply has an unknown code or invalid message."
1680
+ )
1681
+ _utf8_size(message, "A helper error message", MAX_ERROR_MESSAGE_BYTES + 4)
1682
+ return "error", {"code": code, "message": message}
1683
+ if status != "ok":
1684
+ raise TrustedLiveProtocolValidationError(
1685
+ "A helper reply has an invalid status."
1686
+ )
1687
+ return "ok", payload
1688
+
1689
+
1690
+ def validate_startup_success(payload: Mapping[str, Any]) -> TrustedLiveSourceIdentity:
1691
+ if set(payload) != {"status", "source"}:
1692
+ raise TrustedLiveProtocolValidationError(
1693
+ "A startup success reply has invalid fields."
1694
+ )
1695
+ return decode_source_identity(payload["source"])
1696
+
1697
+
1698
+ def validate_shutdown_success(payload: Mapping[str, Any]) -> None:
1699
+ if set(payload) != {"status"}:
1700
+ raise TrustedLiveProtocolValidationError(
1701
+ "A shutdown success reply has invalid fields."
1702
+ )
1703
+
1704
+
1705
+ def validate_job_success(
1706
+ operation: str,
1707
+ payload: Mapping[str, Any],
1708
+ ) -> TrustedQueryResult | tuple[TrustedQueryResult, ...] | int:
1709
+ if operation in {"query", "query_batch"}:
1710
+ if set(payload) != {"status", "results"}:
1711
+ raise TrustedLiveProtocolValidationError(
1712
+ "A query success reply has invalid fields."
1713
+ )
1714
+ results = decode_query_results(payload["results"])
1715
+ if operation == "query":
1716
+ if len(results) != 1:
1717
+ raise TrustedLiveProtocolValidationError(
1718
+ "A query reply must contain exactly one result."
1719
+ )
1720
+ return results[0]
1721
+ return results
1722
+ if operation == "data_version":
1723
+ if set(payload) != {"status", "value"}:
1724
+ raise TrustedLiveProtocolValidationError(
1725
+ "A data-version success reply has invalid fields."
1726
+ )
1727
+ value = payload["value"]
1728
+ if type(value) is not int or value < 0 or value > _INTEGER_MAX:
1729
+ raise TrustedLiveProtocolValidationError(
1730
+ "The data-version reply is invalid."
1731
+ )
1732
+ return value
1733
+ raise TrustedLiveProtocolValidationError(
1734
+ "A success reply used an invalid database operation."
1735
+ )
1736
+
1737
+
1738
+ def error_code_is_terminal(code: str) -> bool:
1739
+ return code not in {
1740
+ "sql_rejected",
1741
+ "query_failed",
1742
+ "result_limit",
1743
+ "busy_timeout",
1744
+ "cancelled",
1745
+ }