onec-interactive-runtime-core 0.1.19__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 (87) hide show
  1. onec_interactive_runtime_core-0.1.19.dist-info/METADATA +12 -0
  2. onec_interactive_runtime_core-0.1.19.dist-info/RECORD +87 -0
  3. onec_interactive_runtime_core-0.1.19.dist-info/WHEEL +4 -0
  4. onec_interactive_runtime_core-0.1.19.dist-info/licenses/COPYRIGHT +9 -0
  5. onec_interactive_runtime_core-0.1.19.dist-info/licenses/LICENSE +232 -0
  6. onec_interactive_runtime_core-0.1.19.dist-info/licenses/THIRD_PARTY_NOTICES.txt +32 -0
  7. onec_runtime/AGENTS.md +7 -0
  8. onec_runtime/__init__.py +3 -0
  9. onec_runtime/artifacts.py +59 -0
  10. onec_runtime/bootstrap.py +310 -0
  11. onec_runtime/breakpoint_workspace.py +249 -0
  12. onec_runtime/bsl/__init__.py +139 -0
  13. onec_runtime/bsl/diagnostics.py +1046 -0
  14. onec_runtime/bsl/full_ast_worker_projection.py +522 -0
  15. onec_runtime/bsl/generated_semantic_parser.py +6322 -0
  16. onec_runtime/bsl/lexer.py +164 -0
  17. onec_runtime/bsl/module_catalog.py +420 -0
  18. onec_runtime/bsl/module_delta.py +1079 -0
  19. onec_runtime/bsl/module_universe.py +1877 -0
  20. onec_runtime/bsl/notebook_cells.py +232 -0
  21. onec_runtime/bsl/notebook_method_globals.py +74 -0
  22. onec_runtime/bsl/notebook_methods.py +175 -0
  23. onec_runtime/bsl/parser_artifact_identity.py +119 -0
  24. onec_runtime/bsl/parser_target.py +388 -0
  25. onec_runtime/bsl/preprocessor.py +16 -0
  26. onec_runtime/bsl/semantic_lowering.py +1539 -0
  27. onec_runtime/bsl/source_maps.py +2411 -0
  28. onec_runtime/bsl/worker_dependency_resolver.py +289 -0
  29. onec_runtime/bsl/worker_preprocessor.py +247 -0
  30. onec_runtime/bsl/worker_projection_model.py +137 -0
  31. onec_runtime/bsl/worker_reload_source_map.py +1697 -0
  32. onec_runtime/capture.py +113 -0
  33. onec_runtime/capture_source.py +453 -0
  34. onec_runtime/compact_table.py +376 -0
  35. onec_runtime/compact_table_backend.py +500 -0
  36. onec_runtime/config.py +198 -0
  37. onec_runtime/configurator_agent.py +520 -0
  38. onec_runtime/controller_worker.py +400 -0
  39. onec_runtime/credentials.py +18 -0
  40. onec_runtime/epf_container.py +196 -0
  41. onec_runtime/errors.py +205 -0
  42. onec_runtime/experiment.py +130 -0
  43. onec_runtime/extension_bundle.py +944 -0
  44. onec_runtime/extension_lifecycle.py +402 -0
  45. onec_runtime/extension_state.py +222 -0
  46. onec_runtime/fault_injection.py +30 -0
  47. onec_runtime/kernel.py +179 -0
  48. onec_runtime/lease.py +123 -0
  49. onec_runtime/observation.py +287 -0
  50. onec_runtime/performance_profile.py +245 -0
  51. onec_runtime/privacy.py +263 -0
  52. onec_runtime/processes.py +265 -0
  53. onec_runtime/prototype_runtime.py +2644 -0
  54. onec_runtime/rdbg/__init__.py +1 -0
  55. onec_runtime/rdbg/models.py +130 -0
  56. onec_runtime/rdbg/reconnect.py +59 -0
  57. onec_runtime/rdbg/session.py +913 -0
  58. onec_runtime/rdbg/transport.py +138 -0
  59. onec_runtime/rdbg/xml_codec.py +980 -0
  60. onec_runtime/recovery.py +106 -0
  61. onec_runtime/recovery_journal.py +79 -0
  62. onec_runtime/resources/extension/OnecInteractiveRuntime.cfe +0 -0
  63. onec_runtime/resources/extension/extension-manifest.json +108 -0
  64. onec_runtime/runtime_api.py +5272 -0
  65. onec_runtime/runtime_contracts.py +196 -0
  66. onec_runtime/server_worker.py +2034 -0
  67. onec_runtime/session.py +2123 -0
  68. onec_runtime/startup_diagnostics.py +45 -0
  69. onec_runtime/stop_routing.py +65 -0
  70. onec_runtime/supervised_processes.py +232 -0
  71. onec_runtime/supervised_runtime.py +512 -0
  72. onec_runtime/supervisor.py +363 -0
  73. onec_runtime/supervisor_model.py +148 -0
  74. onec_runtime/supervisor_protocol.py +77 -0
  75. onec_runtime/table_materialization.py +471 -0
  76. onec_runtime/table_transfer_backend.py +203 -0
  77. onec_runtime/table_value.py +311 -0
  78. onec_runtime/toolchain.py +730 -0
  79. onec_runtime/value_materialization.py +432 -0
  80. onec_runtime/value_transfer_backend.py +199 -0
  81. onec_runtime/worker_breakpoints.py +1431 -0
  82. onec_runtime/worker_epf.py +313 -0
  83. onec_runtime/worker_epf_template.py +53 -0
  84. onec_runtime/worker_stage_protocol.py +516 -0
  85. onec_runtime/worker_universe.py +3772 -0
  86. onec_runtime_build/__init__.py +1 -0
  87. onec_runtime_build/parser_target_development.py +116 -0
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from enum import Enum
5
+ from multiprocessing.connection import Connection
6
+ from threading import Event
7
+ from time import monotonic
8
+ from typing import Protocol
9
+ from uuid import UUID
10
+
11
+ from onec_runtime.errors import (
12
+ CommandTimeout,
13
+ ControllerUnavailable,
14
+ InvalidMessageSequence,
15
+ LeaseConflict,
16
+ LeaseExpired,
17
+ StaleLeaseEpoch,
18
+ )
19
+ from onec_runtime.fault_injection import FaultPoint
20
+ from onec_runtime.lease import LeaseAuthority, validate_lease_ttl
21
+ from onec_runtime.recovery_journal import RecoveryJournal
22
+ from onec_runtime.supervisor_model import LeaseHandle
23
+ from onec_runtime.supervisor_protocol import (
24
+ ControlMessage,
25
+ MessageKind,
26
+ MessageReceiver,
27
+ MessageSender,
28
+ )
29
+
30
+
31
+ WORKER_POLL_INTERVAL_S = 0.05
32
+ HEARTBEAT_INTERVAL_S = 0.25
33
+ DEBUGGEE_START_TIMEOUT_S = 90.0
34
+ SAFE_RUNTIME_STATES = frozenset(
35
+ {
36
+ "idle",
37
+ "main_pending",
38
+ "captured",
39
+ "evaluating_capture",
40
+ "debug_stopped",
41
+ "flushing",
42
+ "partial_writeback_failure",
43
+ "breakpoint_restore_failure",
44
+ "resuming",
45
+ "recovering",
46
+ "lost",
47
+ "completed",
48
+ "failed",
49
+ }
50
+ )
51
+
52
+
53
+ class BarrierMode(str, Enum):
54
+ CRASH = "crash"
55
+ HANG = "hang"
56
+ LEASE_EXPIRY = "lease_expiry"
57
+
58
+
59
+ class _BarrierShutdown(BaseException):
60
+ """Unwinds driver execution when shutdown arrives at a phase barrier."""
61
+
62
+
63
+ Publish = Callable[[MessageKind, dict[str, object]], None]
64
+ BarrierWait = Callable[..., None]
65
+
66
+
67
+ class PhaseBarrier:
68
+ """Publishes a durable phase boundary before entering its wait mode."""
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ point: FaultPoint,
74
+ publish: Publish,
75
+ flush: Callable[[], None],
76
+ wait: BarrierWait,
77
+ mode: BarrierMode = BarrierMode.CRASH,
78
+ payload: dict[str, object] | None = None,
79
+ ) -> None:
80
+ self._point = point
81
+ self._publish = publish
82
+ self._flush = flush
83
+ self._wait = wait
84
+ self._mode = mode
85
+ self._payload = payload or {}
86
+ self._fired = False
87
+
88
+ def __call__(self, point: FaultPoint) -> None:
89
+ if self._fired or point is not self._point:
90
+ return
91
+ self._fired = True
92
+ self._flush()
93
+ self._publish(
94
+ MessageKind.PHASE_REACHED,
95
+ {"point": point.value, **self._payload},
96
+ )
97
+ if self._mode is BarrierMode.HANG:
98
+ self._wait(heartbeat_enabled=False)
99
+ else:
100
+ self._wait()
101
+
102
+
103
+ class RuntimeDriver(Protocol):
104
+ def prepare_debug_ui(self) -> None:
105
+ raise NotImplementedError
106
+
107
+ def attach_runtime(self) -> None:
108
+ raise NotImplementedError
109
+
110
+ def execute_phase(self, point: FaultPoint, barrier: PhaseBarrier) -> object:
111
+ raise NotImplementedError
112
+
113
+ def status(self) -> dict[str, object]:
114
+ raise NotImplementedError
115
+
116
+ def close(self) -> None:
117
+ raise NotImplementedError
118
+
119
+
120
+ class ControllerWorker:
121
+ """Single-writer child control plane for one supervised generation."""
122
+
123
+ def __init__(
124
+ self,
125
+ generation_id: int,
126
+ driver: RuntimeDriver,
127
+ *,
128
+ connection: Connection | None = None,
129
+ emit: Callable[[ControlMessage], None] | None = None,
130
+ clock: Callable[[], float] = monotonic,
131
+ journal: RecoveryJournal | None = None,
132
+ ) -> None:
133
+ if emit is None and connection is None:
134
+ raise ValueError("ControllerWorker requires a connection or emit callback")
135
+ self._generation_id = generation_id
136
+ self._driver = driver
137
+ self._connection = connection
138
+ self._emit = emit if emit is not None else connection.send # type: ignore[union-attr]
139
+ self._clock = clock
140
+ self._journal = journal or RecoveryJournal()
141
+ self._leases = LeaseAuthority(generation_id, clock=clock)
142
+ self._sender = MessageSender(generation_id)
143
+ self._receiver = MessageReceiver(generation_id)
144
+ self._running = True
145
+ self._at_barrier = False
146
+ self._barrier_mode: BarrierMode | None = None
147
+ self._progress_sequence = 0
148
+ self._last_heartbeat_at = clock()
149
+
150
+ def run(self) -> None:
151
+ try:
152
+ try:
153
+ self._run()
154
+ finally:
155
+ self._driver.close()
156
+ except Exception as error:
157
+ self._publish(
158
+ MessageKind.FATAL,
159
+ {"error_type": type(error).__name__},
160
+ )
161
+ raise
162
+
163
+ def _run(self) -> None:
164
+ connection = self._require_connection()
165
+ self._driver.prepare_debug_ui()
166
+ self._advance_progress()
167
+ self._publish(MessageKind.DEBUG_READY, {})
168
+
169
+ deadline = self._clock() + DEBUGGEE_START_TIMEOUT_S
170
+ while True:
171
+ remaining = deadline - self._clock()
172
+ if remaining <= 0:
173
+ raise CommandTimeout("Timed out waiting for debuggee start")
174
+ if not connection.poll(min(WORKER_POLL_INTERVAL_S, remaining)):
175
+ continue
176
+ message = connection.recv()
177
+ self._receiver.accept(message)
178
+ if message.kind is not MessageKind.DEBUGGEE_STARTED:
179
+ raise InvalidMessageSequence(
180
+ f"Unexpected {message.kind.value} before debuggee start"
181
+ )
182
+ self._advance_progress()
183
+ break
184
+
185
+ self._driver.attach_runtime()
186
+ self._advance_progress()
187
+ self._publish(MessageKind.READY, {})
188
+ self._last_heartbeat_at = self._clock()
189
+
190
+ while self._running:
191
+ self._expire_lease()
192
+ if connection.poll(WORKER_POLL_INTERVAL_S):
193
+ self.handle_message(connection.recv())
194
+ self._emit_heartbeat_if_due()
195
+
196
+ def handle_message(self, message: ControlMessage) -> None:
197
+ self._expire_lease()
198
+ self._receiver.accept(message)
199
+
200
+ if message.kind is MessageKind.GRANT_LEASE:
201
+ self._grant_lease(message.payload)
202
+ elif message.kind is MessageKind.RENEW_LEASE:
203
+ self._renew_lease(message.payload)
204
+ elif message.kind is MessageKind.EXECUTE_PHASE:
205
+ try:
206
+ self._execute_phase(message.payload)
207
+ except _BarrierShutdown:
208
+ return
209
+ elif message.kind is MessageKind.STATUS:
210
+ self._status()
211
+ elif message.kind is MessageKind.SHUTDOWN:
212
+ self._running = False
213
+ else:
214
+ raise InvalidMessageSequence(
215
+ f"Unexpected worker command {message.kind.value}"
216
+ )
217
+ self._advance_progress()
218
+
219
+ def poll_once(self) -> None:
220
+ self._expire_lease()
221
+
222
+ def _grant_lease(self, payload: dict[str, object]) -> None:
223
+ try:
224
+ handle = self._leases.grant(
225
+ self._owner(payload),
226
+ ttl_s=validate_lease_ttl(payload["ttl_s"]),
227
+ )
228
+ except (LeaseConflict, LeaseExpired) as error:
229
+ self._publish_lease_conflict(error)
230
+ return
231
+ self._publish_lease_granted(handle)
232
+
233
+ def _renew_lease(self, payload: dict[str, object]) -> None:
234
+ try:
235
+ handle = self._leases.renew(
236
+ self._lease_handle(payload),
237
+ ttl_s=validate_lease_ttl(payload["ttl_s"]),
238
+ )
239
+ except (LeaseConflict, LeaseExpired, StaleLeaseEpoch) as error:
240
+ self._publish_lease_conflict(error)
241
+ return
242
+ self._publish_lease_granted(handle)
243
+
244
+ def _execute_phase(self, payload: dict[str, object]) -> None:
245
+ if self._at_barrier:
246
+ self._publish_lease_conflict(
247
+ ControllerUnavailable("A phase barrier is already active")
248
+ )
249
+ return
250
+ try:
251
+ self._leases.validate(self._lease_handle(payload))
252
+ except (LeaseConflict, LeaseExpired, StaleLeaseEpoch) as error:
253
+ self._publish_lease_conflict(error)
254
+ return
255
+
256
+ point = FaultPoint(str(payload["point"]))
257
+ mode = BarrierMode(str(payload["barrier_mode"]))
258
+ barrier_payload: dict[str, object] = {}
259
+ operation_id_value = payload.get("operation_id")
260
+ operation_id = None
261
+ if operation_id_value is not None:
262
+ operation_id = self._positive_int(operation_id_value, "operation_id")
263
+ barrier_payload["operation_id"] = operation_id
264
+
265
+ barrier = PhaseBarrier(
266
+ point=point,
267
+ mode=mode,
268
+ publish=self._publish,
269
+ flush=self._journal.flush,
270
+ wait=self._wait_at_barrier,
271
+ payload=barrier_payload,
272
+ )
273
+ self._at_barrier = True
274
+ self._barrier_mode = mode
275
+ try:
276
+ self._driver.execute_phase(point, barrier)
277
+ finally:
278
+ self._barrier_mode = None
279
+ self._at_barrier = False
280
+ if not self._running:
281
+ return
282
+ result_payload: dict[str, object] = {"phase": point.value}
283
+ if operation_id is not None:
284
+ result_payload["operation_id"] = operation_id
285
+ self._publish(MessageKind.OPERATION_RESULT, result_payload)
286
+
287
+ def _status(self) -> None:
288
+ lease = self._leases.status()
289
+ driver_status = self._driver.status()
290
+ state = driver_status.get("state")
291
+ safe_state = (
292
+ state
293
+ if type(state) is str and state in SAFE_RUNTIME_STATES
294
+ else "unknown"
295
+ )
296
+ self._publish(
297
+ MessageKind.STATUS_RESULT,
298
+ {
299
+ "state": safe_state,
300
+ "lease_epoch": lease.lease_epoch,
301
+ "expires_in_s": lease.expires_in_s,
302
+ "lease_expired": lease.expired,
303
+ },
304
+ )
305
+
306
+ def _wait_at_barrier(self, *, heartbeat_enabled: bool = True) -> None:
307
+ if not heartbeat_enabled:
308
+ Event().wait()
309
+ return
310
+
311
+ connection = self._require_connection()
312
+ while self._running:
313
+ self._expire_lease()
314
+ if connection.poll(WORKER_POLL_INTERVAL_S):
315
+ self.handle_message(connection.recv())
316
+ if not self._running:
317
+ raise _BarrierShutdown
318
+ self._emit_heartbeat_if_due()
319
+ raise _BarrierShutdown
320
+
321
+ def _expire_lease(self) -> None:
322
+ expired = self._leases.expire_if_due()
323
+ if expired is None:
324
+ return
325
+ self._publish(
326
+ MessageKind.LEASE_EXPIRED,
327
+ {"lease_epoch": expired.lease_epoch},
328
+ )
329
+
330
+ def _emit_heartbeat_if_due(self) -> None:
331
+ now = self._clock()
332
+ if now - self._last_heartbeat_at < HEARTBEAT_INTERVAL_S:
333
+ return
334
+ self._publish(
335
+ MessageKind.HEARTBEAT,
336
+ {
337
+ "progress_sequence": self._progress_sequence,
338
+ "busy_deadline": None,
339
+ },
340
+ )
341
+ self._last_heartbeat_at = now
342
+
343
+ def _publish_lease_granted(self, handle: LeaseHandle) -> None:
344
+ self._publish(
345
+ MessageKind.LEASE_GRANTED,
346
+ {
347
+ "lease_epoch": handle.lease_epoch,
348
+ "expires_in_s": self._leases.status().expires_in_s,
349
+ },
350
+ )
351
+
352
+ def _publish_lease_conflict(self, error: Exception) -> None:
353
+ self._publish(
354
+ MessageKind.LEASE_CONFLICT,
355
+ {"error_type": type(error).__name__},
356
+ )
357
+
358
+ def _publish(self, kind: MessageKind, payload: dict[str, object]) -> None:
359
+ self._emit(self._sender.create(kind, **payload))
360
+
361
+ def _lease_handle(self, payload: dict[str, object]) -> LeaseHandle:
362
+ return LeaseHandle(
363
+ generation_id=self._generation_id,
364
+ owner_id=self._owner(payload),
365
+ lease_epoch=self._positive_int(payload["lease_epoch"], "lease_epoch"),
366
+ )
367
+
368
+ @staticmethod
369
+ def _owner(payload: dict[str, object]) -> UUID:
370
+ return UUID(str(payload["owner_id"]))
371
+
372
+ @staticmethod
373
+ def _positive_int(value: object, field_name: str) -> int:
374
+ if type(value) is not int:
375
+ raise ValueError(f"{field_name} must be an integer")
376
+ if value <= 0:
377
+ raise ValueError(f"{field_name} must be positive")
378
+ return value
379
+
380
+ def _advance_progress(self) -> None:
381
+ self._progress_sequence += 1
382
+
383
+ def _require_connection(self) -> Connection:
384
+ if self._connection is None:
385
+ raise RuntimeError("ControllerWorker command loop requires a connection")
386
+ return self._connection
387
+
388
+
389
+ def controller_worker_main(
390
+ generation_id: int,
391
+ connection: Connection,
392
+ driver: RuntimeDriver,
393
+ journal: RecoveryJournal | None = None,
394
+ ) -> None:
395
+ ControllerWorker(
396
+ generation_id,
397
+ driver,
398
+ connection=connection,
399
+ journal=journal,
400
+ ).run()
@@ -0,0 +1,18 @@
1
+ """Safe diagnostic representations of 1C launch arguments."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+
6
+ def redact_command(command: Sequence[str]) -> tuple[str, ...]:
7
+ """Hide /P values; consume /N values even when they look like switches."""
8
+ result: list[str] = []
9
+ credential: str | None = None
10
+ for argument in command:
11
+ if credential is not None:
12
+ result.append("<redacted>" if credential == "/p" and argument else argument)
13
+ credential = None
14
+ else:
15
+ result.append(argument)
16
+ if argument.casefold() in ("/n", "/p"):
17
+ credential = argument.casefold()
18
+ return tuple(result)
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ """Minimal deterministic codec for the 32-bit 1C container used by Worker EPF.
4
+
5
+ The layout and field interpretation are derived from the MIT-licensed
6
+ ``saby-integration/v8unpack`` container reader/writer (copyright 2015
7
+ Infactum). This module intentionally implements only the bounded format used
8
+ by the one-object-module runtime Worker; it is not a general 1C file library.
9
+ """
10
+
11
+ from collections.abc import Mapping
12
+ from io import BytesIO
13
+ from struct import Struct
14
+ import zlib
15
+
16
+
17
+ _END_MARKER = 0x7FFFFFFF
18
+ _DEFAULT_BLOCK_SIZE = 0x200
19
+ _CONTAINER_HEADER = Struct("<4i")
20
+ _INDEX_ENTRY = Struct("<3i")
21
+ _FILE_ATTRIBUTES = Struct("<QQi")
22
+ _BLOCK_HEADER_SIZE = 31
23
+ _INDEX_OFFSET = _CONTAINER_HEADER.size
24
+
25
+
26
+ class EpfContainerError(ValueError):
27
+ """The byte sequence is outside the supported bounded container format."""
28
+
29
+
30
+ def raw_deflate(payload: bytes) -> bytes:
31
+ compressor = zlib.compressobj(wbits=-15)
32
+ return compressor.compress(payload) + compressor.flush()
33
+
34
+
35
+ def raw_inflate(payload: bytes) -> bytes:
36
+ try:
37
+ decompressor = zlib.decompressobj(wbits=-15)
38
+ result = decompressor.decompress(payload) + decompressor.flush()
39
+ except zlib.error as error:
40
+ raise EpfContainerError("Invalid raw-deflate stream") from error
41
+ if not decompressor.eof:
42
+ raise EpfContainerError("Invalid raw-deflate stream is truncated")
43
+ if decompressor.unused_data or decompressor.unconsumed_tail:
44
+ raise EpfContainerError("Invalid raw-deflate stream contains trailing data")
45
+ return result
46
+
47
+
48
+ def build_container(entries: Mapping[str, bytes]) -> bytes:
49
+ ordered = tuple(sorted(entries.items()))
50
+ if not ordered:
51
+ raise EpfContainerError("Container must contain at least one entry")
52
+ if len({name for name, _payload in ordered}) != len(ordered):
53
+ raise EpfContainerError("Container entry names must be unique")
54
+
55
+ stream = BytesIO()
56
+ stream.write(
57
+ _CONTAINER_HEADER.pack(
58
+ _END_MARKER,
59
+ _DEFAULT_BLOCK_SIZE,
60
+ len(ordered),
61
+ 0,
62
+ )
63
+ )
64
+ stream.write(b"\x00" * (_BLOCK_HEADER_SIZE + _DEFAULT_BLOCK_SIZE))
65
+
66
+ table: list[tuple[int, int]] = []
67
+ for name, payload in ordered:
68
+ if not name or "\x00" in name:
69
+ raise EpfContainerError("Container entry name is invalid")
70
+ if not isinstance(payload, bytes):
71
+ raise TypeError("Container entries must contain bytes")
72
+ attributes = (
73
+ _FILE_ATTRIBUTES.pack(0, 0, 0)
74
+ + name.encode("utf-16-le")
75
+ + b"\x00" * 4
76
+ )
77
+ attribute_offset = _write_document(stream, attributes)
78
+ data_offset = _write_document(
79
+ stream,
80
+ payload,
81
+ minimum_block_size=_DEFAULT_BLOCK_SIZE,
82
+ )
83
+ table.append((attribute_offset, data_offset))
84
+
85
+ toc = b"".join(
86
+ _INDEX_ENTRY.pack(attribute_offset, data_offset, _END_MARKER)
87
+ for attribute_offset, data_offset in table
88
+ )
89
+ _write_document(
90
+ stream,
91
+ toc,
92
+ minimum_block_size=_DEFAULT_BLOCK_SIZE,
93
+ offset=_INDEX_OFFSET,
94
+ )
95
+ return stream.getvalue()
96
+
97
+
98
+ def read_container(payload: bytes) -> dict[str, bytes]:
99
+ if len(payload) < _INDEX_OFFSET + _BLOCK_HEADER_SIZE:
100
+ raise EpfContainerError("Container is truncated")
101
+ try:
102
+ end_marker, block_size, count, reserved = _CONTAINER_HEADER.unpack_from(
103
+ payload, 0
104
+ )
105
+ except Exception as error:
106
+ raise EpfContainerError("Container header is invalid") from error
107
+ if (
108
+ end_marker != _END_MARKER
109
+ or block_size != _DEFAULT_BLOCK_SIZE
110
+ or count <= 0
111
+ or reserved != 0
112
+ ):
113
+ raise EpfContainerError("Container header is unsupported")
114
+
115
+ toc = _read_document(payload, _INDEX_OFFSET)
116
+ if not toc or len(toc) % _INDEX_ENTRY.size:
117
+ raise EpfContainerError("Container table of contents is invalid")
118
+
119
+ result: dict[str, bytes] = {}
120
+ # Platform-built nested module containers may retain a header count that is
121
+ # one lower than the actual TOC entry count. v8unpack likewise treats the
122
+ # bounded TOC document as authoritative.
123
+ toc_count = len(toc) // _INDEX_ENTRY.size
124
+ if toc_count not in {count, count + 1}:
125
+ raise EpfContainerError("Container entry count does not match its bounded TOC")
126
+ for index in range(toc_count):
127
+ position = index * _INDEX_ENTRY.size
128
+ attribute_offset, data_offset, marker = _INDEX_ENTRY.unpack_from(toc, position)
129
+ if marker != _END_MARKER:
130
+ raise EpfContainerError("Container table marker is invalid")
131
+ attributes = _read_document(payload, attribute_offset)
132
+ if len(attributes) < _FILE_ATTRIBUTES.size + 4:
133
+ raise EpfContainerError("Container entry attributes are truncated")
134
+ name_bytes = attributes[_FILE_ATTRIBUTES.size :]
135
+ try:
136
+ name = name_bytes.decode("utf-16-le").split("\x00", 1)[0]
137
+ except UnicodeDecodeError as error:
138
+ raise EpfContainerError("Container entry name is invalid") from error
139
+ if not name or name in result:
140
+ raise EpfContainerError("Container entry name is empty or duplicated")
141
+ result[name] = _read_document(payload, data_offset)
142
+ return result
143
+
144
+
145
+ def _write_document(
146
+ stream: BytesIO,
147
+ data: bytes,
148
+ *,
149
+ minimum_block_size: int = 0,
150
+ offset: int | None = None,
151
+ ) -> int:
152
+ if offset is None:
153
+ stream.seek(0, 2)
154
+ offset = stream.tell()
155
+ else:
156
+ stream.seek(offset)
157
+ current_block_size = max(minimum_block_size, len(data))
158
+ if current_block_size > _END_MARKER:
159
+ raise EpfContainerError("Container document is too large")
160
+ header = (
161
+ f"\r\n{len(data):08x} {current_block_size:08x} "
162
+ f"{_END_MARKER:08x} \r\n"
163
+ ).encode("ascii")
164
+ if len(header) != _BLOCK_HEADER_SIZE:
165
+ raise AssertionError("Unexpected 1C block header size")
166
+ stream.write(header)
167
+ stream.write(data)
168
+ stream.write(b"\x00" * (current_block_size - len(data)))
169
+ return offset
170
+
171
+
172
+ def _read_document(payload: bytes, offset: int) -> bytes:
173
+ if offset < _INDEX_OFFSET or offset + _BLOCK_HEADER_SIZE > len(payload):
174
+ raise EpfContainerError("Container document offset is outside the file")
175
+ header = payload[offset : offset + _BLOCK_HEADER_SIZE]
176
+ if (
177
+ header[:2] != b"\r\n"
178
+ or header[10:11] != b" "
179
+ or header[19:20] != b" "
180
+ or header[28:29] != b" "
181
+ or header[29:] != b"\r\n"
182
+ ):
183
+ raise EpfContainerError("Container document header is invalid")
184
+ try:
185
+ document_size = int(header[2:10], 16)
186
+ current_block_size = int(header[11:19], 16)
187
+ next_offset = int(header[20:28], 16)
188
+ except ValueError as error:
189
+ raise EpfContainerError("Container document size is invalid") from error
190
+ if current_block_size < document_size or next_offset != _END_MARKER:
191
+ raise EpfContainerError("Chained container documents are unsupported")
192
+ start = offset + _BLOCK_HEADER_SIZE
193
+ end = start + current_block_size
194
+ if end > len(payload):
195
+ raise EpfContainerError("Container document is truncated")
196
+ return payload[start : start + document_size]