plan-manager-client-dev 0.2.186__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 (37) hide show
  1. plan_manager_client/__init__.py +10 -0
  2. plan_manager_client/_version.py +58 -0
  3. plan_manager_client/authoring_diagnostics.py +137 -0
  4. plan_manager_client/client.py +75 -0
  5. plan_manager_client/config.py +320 -0
  6. plan_manager_client/delivery/__init__.py +7 -0
  7. plan_manager_client/delivery/archive.py +140 -0
  8. plan_manager_client/delivery/atomicity.py +93 -0
  9. plan_manager_client/delivery/commit.py +113 -0
  10. plan_manager_client/delivery/destination.py +140 -0
  11. plan_manager_client/delivery/provenance.py +88 -0
  12. plan_manager_client/delivery/upload.py +118 -0
  13. plan_manager_client/dispatch.py +380 -0
  14. plan_manager_client/export_fetch_chunks.py +166 -0
  15. plan_manager_client/export_fetch_request.py +88 -0
  16. plan_manager_client/export_fetch_unpack.py +149 -0
  17. plan_manager_client/export_fetch_verify.py +101 -0
  18. plan_manager_client/facade_agent_config_mixin.py +224 -0
  19. plan_manager_client/facade_bug_project_mixin.py +202 -0
  20. plan_manager_client/facade_context_gate_mixin.py +303 -0
  21. plan_manager_client/facade_entity_common_mixin.py +38 -0
  22. plan_manager_client/facade_hrs_file_mixin.py +206 -0
  23. plan_manager_client/facade_plan_hrs_mixin.py +200 -0
  24. plan_manager_client/facade_runtime_mixin/__init__.py +11 -0
  25. plan_manager_client/facade_runtime_mixin/authoring_part.py +442 -0
  26. plan_manager_client/facade_runtime_mixin/facade.py +380 -0
  27. plan_manager_client/facade_runtime_mixin/overlay_part.py +192 -0
  28. plan_manager_client/facade_step_graph_mixin.py +158 -0
  29. plan_manager_client/id_rule.py +115 -0
  30. plan_manager_client/info_walker.py +133 -0
  31. plan_manager_client/scenario_runtime.py +65 -0
  32. plan_manager_client/server_api.py +427 -0
  33. plan_manager_client/server_api_mutating.py +105 -0
  34. plan_manager_client_dev-0.2.186.dist-info/METADATA +10 -0
  35. plan_manager_client_dev-0.2.186.dist-info/RECORD +37 -0
  36. plan_manager_client_dev-0.2.186.dist-info/WHEEL +5 -0
  37. plan_manager_client_dev-0.2.186.dist-info/top_level.txt +1 -0
@@ -0,0 +1,118 @@
1
+ """Path A digest-verified CA tree upload composition.
2
+
3
+ Walks the materialized export tree, verifying each entry against its
4
+ digest (DeliveryIntegrityContract, MRS concept C-007) BEFORE uploading it
5
+ through the CA service's own client (CodeAnalysisClientDependency, MRS
6
+ concept C-015) to its own destination path, so the delivered tree
7
+ reproduces the export layout rather than a flat set of names, realizing
8
+ the upload phase of PathACodeAnalysisDelivery (MRS concept C-003).
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import hashlib
13
+ from dataclasses import dataclass
14
+ from typing import Literal
15
+ from code_analysis_client.client import CodeAnalysisAsyncClient
16
+
17
+
18
+ class DigestMismatchError(Exception):
19
+ """Raised when a tree entry's content does not match its digest.
20
+
21
+ Raised by :func:`verify_and_upload_tree` before any CA-side write is
22
+ attempted for the mismatched entry, per DeliveryIntegrityContract
23
+ (MRS concept C-007).
24
+ """
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PendingEntry:
30
+ """One export tree entry queued for digest-verified upload.
31
+
32
+ Attributes:
33
+ relative_path: The entry's POSIX path relative to the plan export
34
+ root, preserved unchanged end to end (for example
35
+ 'G-001-x/T-001-y/atomic_steps/A-001-z.yaml').
36
+ destination_path: The resolved project-relative POSIX destination
37
+ path inside the CA project, already carrying relative_path's
38
+ position beneath the destination subdirectory.
39
+ content: The entry's unpacked content bytes.
40
+ declared_digest: The declared sha256 hex digest for content.
41
+ already_indexed: True selects UPDATE mode (upload); False selects
42
+ CREATE mode (upload_new).
43
+ """
44
+ relative_path: str
45
+ destination_path: str
46
+ content: bytes
47
+ declared_digest: str
48
+ already_indexed: bool
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class UploadResult:
53
+ """Outcome of one tree entry's digest-verified upload to the CA project."""
54
+ relative_path: str
55
+ destination_path: str
56
+ mode: Literal["create", "update"]
57
+ ca_file_id: str
58
+ success: bool
59
+
60
+
61
+ async def verify_and_upload_tree(
62
+ ca_client: CodeAnalysisAsyncClient,
63
+ resolved_project_id: str,
64
+ pending_entries: list[PendingEntry],
65
+ ) -> list[UploadResult]:
66
+ """Digest-verify and upload each tree entry through the CA client.
67
+
68
+ Args:
69
+ ca_client: An already-connected CodeAnalysisAsyncClient instance
70
+ (MRS concept C-015); this function does not construct or close it.
71
+ resolved_project_id: The CA project_id to upload into.
72
+ pending_entries: Tree entries queued for upload, in tree order.
73
+
74
+ Returns:
75
+ One UploadResult per entry of pending_entries, in the same order.
76
+
77
+ Raises:
78
+ DigestMismatchError: If, for any entry, hashlib.sha256(entry.content)
79
+ .hexdigest() does not equal entry.declared_digest. Processing
80
+ stops at the first mismatch: no upload call is made for that
81
+ entry or any later entry.
82
+ """
83
+ result: list[UploadResult] = []
84
+ file_sessions = ca_client.file_sessions
85
+
86
+ for entry in pending_entries:
87
+ digest = hashlib.sha256(entry.content).hexdigest()
88
+
89
+ if digest != entry.declared_digest:
90
+ raise DigestMismatchError(
91
+ f"digest mismatch for {entry.relative_path!r} at {entry.destination_path!r}"
92
+ )
93
+
94
+ if entry.already_indexed is False:
95
+ ca_file_id = await file_sessions.upload_new(
96
+ project_id=resolved_project_id,
97
+ file_path=entry.destination_path,
98
+ content=entry.content,
99
+ )
100
+ mode = "create"
101
+ else:
102
+ ca_file_id = await file_sessions.upload(
103
+ file_id=entry.destination_path,
104
+ content=entry.content,
105
+ )
106
+ mode = "update"
107
+
108
+ result.append(
109
+ UploadResult(
110
+ relative_path=entry.relative_path,
111
+ destination_path=entry.destination_path,
112
+ mode=mode,
113
+ ca_file_id=str(ca_file_id),
114
+ success=True,
115
+ )
116
+ )
117
+
118
+ return result
@@ -0,0 +1,380 @@
1
+ """Command dispatch mixin: thin async wrapper over the held JsonRpcClient.execute_command_unified.
2
+
3
+ Author: Vasiliy Zdanovskiy
4
+ email: vasilyvz@gmail.com
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+ from mcp_proxy_adapter.client.jsonrpc_client.exceptions import CommandSessionFailedError
13
+
14
+ from plan_manager_client.authoring_diagnostics import (
15
+ AUTHORING_DIAGNOSTIC_COMMANDS,
16
+ emit as emit_authoring_diagnostic,
17
+ new_correlation_id,
18
+ )
19
+ from plan_manager_client.id_rule import InvalidIdentifierError, check_call_ids
20
+
21
+
22
+ def _adapter_validation_error_result(status: object) -> dict[str, Any] | None:
23
+ """Return an adapter validation ErrorResult from a terminal job status.
24
+
25
+ ``queue_get_job_status`` has returned both the ErrorResult itself and the
26
+ queue-status envelope containing it across adapter transport paths. Only
27
+ the documented result locations are considered; a failed job is otherwise
28
+ left as the adapter's ``CommandSessionFailedError``.
29
+ """
30
+ candidates: list[object] = [status]
31
+ if isinstance(status, dict):
32
+ candidates.extend((status.get("result"), status.get("data")))
33
+ result = status.get("result")
34
+ if isinstance(result, dict):
35
+ candidates.append(result.get("data"))
36
+ # A refused command run as a job keeps the ErrorResult one level deeper,
37
+ # at status.result.result and status.data.result.result, with
38
+ # command_execution true (bug ccb5f95f).
39
+ candidates.append(result.get("result"))
40
+ data = status.get("data")
41
+ if isinstance(data, dict):
42
+ data_result = data.get("result")
43
+ if isinstance(data_result, dict):
44
+ candidates.append(data_result.get("result"))
45
+
46
+ for candidate in candidates:
47
+ if not isinstance(candidate, dict):
48
+ continue
49
+ error = candidate.get("error")
50
+ if (
51
+ candidate.get("success") is False
52
+ and isinstance(error, dict)
53
+ and error.get("code") == -32602
54
+ ):
55
+ return candidate
56
+
57
+ # The server's pre-execution refusals (command_execution false) and "Method not
58
+ # found" keep a bare ``{status: "error", code, message, data}`` result at
59
+ # status.data.result / status.result, with no success/error keys (bug ccb5f95f).
60
+ if isinstance(status, dict):
61
+ bare_candidates: list[object] = []
62
+ for holder in (status.get("result"), status.get("data")):
63
+ bare_candidates.append(holder)
64
+ if isinstance(holder, dict):
65
+ bare_candidates.append(holder.get("result"))
66
+ for bare in bare_candidates:
67
+ if (
68
+ isinstance(bare, dict)
69
+ and bare.get("status") == "error"
70
+ and bare.get("code") in _BARE_REFUSAL_CODES
71
+ and isinstance(bare.get("message"), str)
72
+ ):
73
+ bare_data = bare.get("data")
74
+ return {
75
+ "success": False,
76
+ "error": {
77
+ "code": bare["code"],
78
+ "message": bare["message"],
79
+ "data": bare_data if isinstance(bare_data, dict) else {},
80
+ },
81
+ }
82
+
83
+ # A command that EXECUTED and ended with its own ErrorResult (command_execution true,
84
+ # job_success false) keeps that ErrorResult at status.data.result.result (bug 970e0443:
85
+ # ``help`` with an unknown cmdname, code -32601). The caller gets the job's own error
86
+ # envelope, as for a non-queued command, never a CommandSessionFailedError.
87
+ if isinstance(status, dict):
88
+ executed = status.get("data")
89
+ if (
90
+ isinstance(executed, dict)
91
+ and executed.get("status") == "failed"
92
+ and executed.get("command_execution") is True
93
+ and executed.get("job_success") is False
94
+ ):
95
+ executed_result = executed.get("result")
96
+ inner = executed_result.get("result") if isinstance(executed_result, dict) else None
97
+ if (
98
+ isinstance(inner, dict)
99
+ and inner.get("success") is False
100
+ and isinstance(inner.get("error"), dict)
101
+ and isinstance(inner["error"].get("code"), int)
102
+ and isinstance(inner["error"].get("message"), str)
103
+ ):
104
+ return inner
105
+
106
+ # A command-session failure persists the queue terminal status instead of
107
+ # the ErrorResult. Adapter pre-execution validation failures retain an
108
+ # unambiguous status discriminator and the original ValidationError message.
109
+ status_data = status.get("data") if isinstance(status, dict) else None
110
+ if not isinstance(status_data, dict):
111
+ return None
112
+ message = status_data.get("error")
113
+ if message is None:
114
+ # A ValidationError raised by a command's own ``validate_params`` inside
115
+ # the queue worker leaves ``error`` null; the text survives only in
116
+ # ``description`` / ``result.message`` behind the worker's prefix.
117
+ prefix = "Command execution failed: "
118
+ result_part = status_data.get("result")
119
+ for key_text in (
120
+ status_data.get("description"),
121
+ result_part.get("message") if isinstance(result_part, dict) else None,
122
+ ):
123
+ if isinstance(key_text, str) and key_text.startswith(prefix):
124
+ message = key_text.removeprefix(prefix)
125
+ break
126
+ if not (
127
+ status_data.get("status") == "failed"
128
+ and status_data.get("command_execution") is False
129
+ and status_data.get("job_success") is False
130
+ and isinstance(message, str)
131
+ ):
132
+ return None
133
+
134
+ missing_prefix = "Missing required parameters: "
135
+ if message.startswith(missing_prefix):
136
+ missing_parameters = [
137
+ parameter.strip()
138
+ for parameter in message.removeprefix(missing_prefix).split(",")
139
+ if parameter.strip()
140
+ ]
141
+ if not missing_parameters:
142
+ return None
143
+ return {
144
+ "success": False,
145
+ "error": {
146
+ "code": -32602,
147
+ "message": message,
148
+ "data": {"missing_parameters": missing_parameters, "violation": "missing"},
149
+ },
150
+ }
151
+
152
+ # Value messages (``must be one of`` / ``must be false when`` ...) are tried BEFORE the
153
+ # generic type pattern: ``[^,]+`` in the type regex also matches ``be false when hard=true (...)``.
154
+ value_error = re.fullmatch(
155
+ r"[A-Za-z_][A-Za-z0-9_]*: parameter '(?P<field>[^']+)' must (?:be one of|be >=|be <=|be at least|be at most|match pattern|be false when) .*",
156
+ message,
157
+ flags=re.S,
158
+ )
159
+ if value_error is not None:
160
+ return {
161
+ "success": False,
162
+ "error": {
163
+ "code": -32602,
164
+ "message": message,
165
+ "data": {"field": value_error.group("field"), "violation": "value"},
166
+ },
167
+ }
168
+
169
+ type_error = re.fullmatch(
170
+ r"[A-Za-z_][A-Za-z0-9_]*: parameter '(?P<field>[^']+)' "
171
+ r"must be (?P<expected>[^,]+), got (?P<got>[A-Za-z_][A-Za-z0-9_]*)",
172
+ message,
173
+ )
174
+ if type_error is not None:
175
+ return {
176
+ "success": False,
177
+ "error": {
178
+ "code": -32602,
179
+ "message": message,
180
+ "data": {
181
+ "field": type_error.group("field"),
182
+ "violation": "type",
183
+ "expected": type_error.group("expected"),
184
+ "got": type_error.group("got"),
185
+ },
186
+ },
187
+ }
188
+
189
+ # The adapter's own phase-1 texts carry their violation class; the queue keeps only
190
+ # the text, so restore the structured ``data`` the direct (non-queued) path returns.
191
+ unknown_match = re.fullmatch(r"Invalid parameters: (?P<names>.+?)\. Allowed parameters: .*", message, flags=re.S)
192
+ if unknown_match is not None:
193
+ return {
194
+ "success": False,
195
+ "error": {
196
+ "code": -32602,
197
+ "message": message,
198
+ "data": {
199
+ "invalid_parameters": [n.strip() for n in unknown_match.group("names").split(",") if n.strip()],
200
+ "violation": "unknown",
201
+ },
202
+ },
203
+ }
204
+
205
+ # The adapter keeps no structured code on a failed queue job: only the
206
+ # exception text survives. A job that failed with ``command_execution`` False
207
+ # (checked above) never reached ``execute()``; the adapter turns every
208
+ # domain/runtime failure inside ``execute()`` into a completed ErrorResult, so
209
+ # an exception escaping ``Command.run()`` before that point is the
210
+ # parameter-validation phase (``validate_params``: unknown/missing/typed/
211
+ # vocabulary/UUID-format parameters). Messages of that phase are free text
212
+ # (command-specific), so they cannot all be enumerated. Queue lifecycle
213
+ # failures (stopped, timed out, worker crash, deleted) are NOT validation and
214
+ # must stay a CommandSessionFailedError.
215
+ lowered = message.lower()
216
+ if not message.startswith(_UNKNOWN_PARAMETERS_PREFIX) and any(
217
+ marker in lowered for marker in _NON_VALIDATION_MARKERS
218
+ ):
219
+ return None
220
+ return {
221
+ "success": False,
222
+ "error": {
223
+ "code": -32602,
224
+ "message": message,
225
+ "data": {"recovered_from": "queued_job_status", "violation": _relation_violation(lowered)},
226
+ },
227
+ }
228
+
229
+
230
+ def _relation_violation(lowered: str) -> str:
231
+ """Classify a free-text ``validate_params`` relation failure into the adapter vocabulary."""
232
+ if "must be absent" in lowered:
233
+ return "unknown"
234
+ if " is required" in lowered or " requires " in lowered:
235
+ return "missing"
236
+ return "value"
237
+
238
+
239
+ _BARE_REFUSAL_CODES = (-32602, -32601)
240
+ _UNKNOWN_PARAMETERS_PREFIX = "Invalid parameters: "
241
+ _NON_VALIDATION_MARKERS = (
242
+ "timed out",
243
+ "was stopped",
244
+ "was terminated",
245
+ "was cancelled",
246
+ "was canceled",
247
+ "job deleted",
248
+ "traceback",
249
+ "internal error",
250
+ )
251
+
252
+
253
+ async def _queued_validation_error_result(
254
+ rpc: Any, exc: CommandSessionFailedError
255
+ ) -> dict[str, Any] | None:
256
+ """Recover only a terminal adapter validation result from a failed session."""
257
+ details = getattr(exc, "details", None)
258
+ if not isinstance(details, dict):
259
+ return None
260
+ terminal_event = details.get("terminal_event")
261
+ if not isinstance(terminal_event, dict):
262
+ return None
263
+ job_id = terminal_event.get("job_id")
264
+ if not isinstance(job_id, str) or not job_id:
265
+ return None
266
+
267
+ try:
268
+ status = await rpc.queue_get_job_status(job_id)
269
+ except Exception:
270
+ # A status retrieval failure must not change the command failure that
271
+ # the adapter already classified.
272
+ return None
273
+ return _adapter_validation_error_result(status)
274
+
275
+
276
+ class _CommandDispatchMixin:
277
+ """Mixin providing the single dispatch point used by every command-family mixin.
278
+
279
+ Assumes it is mixed into a class that stores a composed JsonRpcClient on the
280
+ instance attribute ``self._rpc`` (an instance of
281
+ mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient), which supplies
282
+ the coroutine:
283
+
284
+ async def execute_command_unified(
285
+ self,
286
+ command: str,
287
+ params: Optional[Dict[str, Any]] = None,
288
+ *,
289
+ use_cmd_endpoint: bool = False,
290
+ expect_queue: Optional[bool] = None,
291
+ auto_poll: bool = True,
292
+ poll_interval: float = 1.0,
293
+ timeout: Optional[float] = None,
294
+ status_hook: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
295
+ manual_event_handling: bool = False,
296
+ ) -> Any
297
+
298
+ which returns {"mode": "immediate", "result": ...} for immediate commands or
299
+ {"mode": "queued", "job_id": ..., "status": ..., "result": ...} for commands
300
+ the server ran via the queue (auto_poll=True waits for completion via
301
+ WebSocket before returning, applying queue_get_job_status semantics). The
302
+ composed JsonRpcClient is held, never inherited, so no transport coroutine
303
+ leaks onto the public facade surface.
304
+ """
305
+
306
+ async def _call(self, command: str, params: dict[str, Any] | None = None) -> Any:
307
+ """Dispatch one plan_manager command and return its unwrapped result.
308
+
309
+ Always calls self._rpc.execute_command_unified(command, params, auto_poll=True)
310
+ so a queued command is auto-polled to completion (queue_get_job_status
311
+ semantics) before this coroutine returns. The held JsonRpcClient's
312
+ configured timeout is forwarded unchanged so the adapter's own terminal
313
+ wait remains bounded by the caller-selected budget. Returns the "result"
314
+ value from the unified response for both immediate and queued commands.
315
+ """
316
+ try:
317
+ check_call_ids(params, command) # uuid4 discipline (block 2, D7): an invalid identifier is refused before the call is sent
318
+ except InvalidIdentifierError as refusal:
319
+ # The refusal is the command's own answer (-32602, violation value), never an exception out of the facade (0153 GI2).
320
+ return refusal.as_answer()
321
+ diagnostic = command in AUTHORING_DIAGNOSTIC_COMMANDS
322
+ correlation_id = new_correlation_id() if diagnostic else ""
323
+ if diagnostic:
324
+ emit_authoring_diagnostic(
325
+ "client.dispatch.start",
326
+ command=command,
327
+ correlation_id=correlation_id,
328
+ parameters=params or {},
329
+ )
330
+ try:
331
+ response = await self._rpc.execute_command_unified( # type: ignore[attr-defined]
332
+ command,
333
+ params or {},
334
+ auto_poll=True,
335
+ timeout=getattr(self._rpc, "timeout", None), # type: ignore[attr-defined]
336
+ )
337
+ except CommandSessionFailedError as exc:
338
+ validation_error = await _queued_validation_error_result(self._rpc, exc)
339
+ if validation_error is not None:
340
+ if diagnostic:
341
+ emit_authoring_diagnostic(
342
+ "client.response.error",
343
+ command=command,
344
+ correlation_id=correlation_id,
345
+ parameters=params or {},
346
+ error=validation_error,
347
+ )
348
+ return validation_error
349
+ if diagnostic:
350
+ emit_authoring_diagnostic(
351
+ "client.response.error",
352
+ command=command,
353
+ correlation_id=correlation_id,
354
+ parameters=params or {},
355
+ error=exc,
356
+ )
357
+ raise
358
+ result = response.get("result")
359
+ if diagnostic:
360
+ event = "client.response.error" if _is_error_result(result) else "client.response.success"
361
+ emit_authoring_diagnostic(
362
+ event,
363
+ command=command,
364
+ correlation_id=correlation_id,
365
+ parameters=params or {},
366
+ result=None if event.endswith("error") else result,
367
+ error=result if event.endswith("error") else None,
368
+ )
369
+ return result
370
+
371
+
372
+ def _is_error_result(result: object) -> bool:
373
+ return (
374
+ isinstance(result, dict)
375
+ and result.get("success") is False
376
+ and isinstance(result.get("error"), dict)
377
+ )
378
+
379
+
380
+ __all__ = ["_CommandDispatchMixin"]
@@ -0,0 +1,166 @@
1
+ """Chunked retrieval and in-memory reassembly of exported plan files (Path B).
2
+
3
+ The plan_manager server serves exported files only through the export_read command, which
4
+ returns a bounded base64 chunk of one file together with the whole file's size, its sha256
5
+ digest, and an end-of-file marker. This module drives that command in a loop to reassemble
6
+ each requested file byte-identically in memory. Nothing here touches the filesystem and
7
+ nothing here verifies the digest: the reassembled buffer carries the digest the server
8
+ declared so a later verification gate can check it before any byte is persisted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from dataclasses import dataclass
15
+ from typing import Any, Protocol, Sequence
16
+
17
+ # Maximum number of DECODED bytes export_read returns in a single chunk.
18
+ MAX_CHUNK_BYTES: int = 262144
19
+
20
+
21
+ class ExportChunkProtocolError(RuntimeError):
22
+ """Raised when an export_read chunk sequence cannot be reassembled.
23
+
24
+ Covers a chunk that makes no progress before the end-of-file marker, a file whose
25
+ declared size or digest changes between chunks, and a reassembled length that
26
+ disagrees with the declared total size. It names only the plan-relative file name:
27
+ it never carries connection, credential, or server-side path information.
28
+ """
29
+
30
+ def __init__(self, filename: str, detail: str) -> None:
31
+ super().__init__(f"export chunk protocol error for {filename!r}: {detail}")
32
+ self.filename = filename
33
+ self.detail = detail
34
+
35
+
36
+ class SupportsExportRead(Protocol):
37
+ """The single export_read call this module needs from a client facade."""
38
+
39
+ async def export_read(
40
+ self, plan: str, file: str, offset: int = 0, limit: int = MAX_CHUNK_BYTES
41
+ ) -> dict[str, Any]:
42
+ ...
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ReassembledFile:
47
+ """One exported file reassembled in memory, pending digest verification.
48
+
49
+ Attributes:
50
+ filename: The plan-relative name of the file, exactly as requested from the server.
51
+ content: The fully reassembled bytes of the file, in original order.
52
+ declared_total_size: The whole-file size the server declared for this file.
53
+ declared_sha256: The whole-file hex sha256 digest the server declared for this file.
54
+ """
55
+
56
+ filename: str
57
+ content: bytes
58
+ declared_total_size: int
59
+ declared_sha256: str
60
+
61
+
62
+ async def fetch_export_file(
63
+ client: SupportsExportRead,
64
+ plan: str,
65
+ file: str,
66
+ chunk_limit: int = MAX_CHUNK_BYTES,
67
+ ) -> ReassembledFile:
68
+ """Reassemble one exported file in memory by looping export_read.
69
+
70
+ Args:
71
+ client: An object exposing the export_read coroutine.
72
+ plan: The plan identifier whose export directory owns the file.
73
+ file: The plan-relative path of the file to retrieve.
74
+ chunk_limit: Maximum decoded bytes to request per chunk; must be in [1, 262144].
75
+
76
+ Returns:
77
+ The ReassembledFile carrying the file's bytes and the server-declared size and digest.
78
+
79
+ Raises:
80
+ ValueError: If chunk_limit is outside [1, 262144].
81
+ ExportChunkProtocolError: If the chunk sequence cannot be reassembled.
82
+ """
83
+ if chunk_limit < 1 or chunk_limit > MAX_CHUNK_BYTES:
84
+ raise ValueError(f"chunk_limit must be an integer in [1, {MAX_CHUNK_BYTES}]")
85
+
86
+ buffer = bytearray()
87
+ offset = 0
88
+ declared_total_size: int | None = None
89
+ declared_sha256: str | None = None
90
+
91
+ while True:
92
+ response = await client.export_read(
93
+ plan=plan, file=file, offset=offset, limit=chunk_limit
94
+ )
95
+ total_size = int(response["total_size"])
96
+ sha256 = str(response["sha256"])
97
+ if declared_total_size is None:
98
+ declared_total_size = total_size
99
+ declared_sha256 = sha256
100
+ elif total_size != declared_total_size or sha256 != declared_sha256:
101
+ raise ExportChunkProtocolError(
102
+ file, "declared size or digest changed between chunks"
103
+ )
104
+
105
+ chunk = base64.b64decode(response["chunk_base64"])
106
+ eof = bool(response["eof"])
107
+ if not chunk and not eof:
108
+ raise ExportChunkProtocolError(
109
+ file, "server returned an empty chunk before the end of the file"
110
+ )
111
+
112
+ buffer.extend(chunk)
113
+ offset += len(chunk)
114
+ if eof:
115
+ break
116
+
117
+ if declared_total_size is None or declared_sha256 is None:
118
+ raise ExportChunkProtocolError(file, "no chunk was returned")
119
+ if len(buffer) != declared_total_size:
120
+ raise ExportChunkProtocolError(
121
+ file, "reassembled length does not match the declared total size"
122
+ )
123
+
124
+ return ReassembledFile(
125
+ filename=file,
126
+ content=bytes(buffer),
127
+ declared_total_size=declared_total_size,
128
+ declared_sha256=declared_sha256,
129
+ )
130
+
131
+
132
+ async def fetch_export_files(
133
+ client: SupportsExportRead,
134
+ plan: str,
135
+ files: Sequence[str],
136
+ chunk_limit: int = MAX_CHUNK_BYTES,
137
+ ) -> list[ReassembledFile]:
138
+ """Reassemble every named exported file, in the order given.
139
+
140
+ Args:
141
+ client: An object exposing the export_read coroutine.
142
+ plan: The plan identifier whose export directory owns the files.
143
+ files: The plan-relative paths of the files to retrieve.
144
+ chunk_limit: Maximum decoded bytes to request per chunk; must be in [1, 262144].
145
+
146
+ Returns:
147
+ One ReassembledFile per requested file, in the same order as files.
148
+
149
+ Raises:
150
+ ValueError: If chunk_limit is outside [1, 262144].
151
+ ExportChunkProtocolError: If any file's chunk sequence cannot be reassembled.
152
+ """
153
+ return [
154
+ await fetch_export_file(client, plan, file, chunk_limit=chunk_limit)
155
+ for file in files
156
+ ]
157
+
158
+
159
+ __all__ = [
160
+ "MAX_CHUNK_BYTES",
161
+ "ExportChunkProtocolError",
162
+ "ReassembledFile",
163
+ "SupportsExportRead",
164
+ "fetch_export_file",
165
+ "fetch_export_files",
166
+ ]