annoscript 0.1.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 (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
annoscript/preview.py ADDED
@@ -0,0 +1,115 @@
1
+ """Preview resolved logical calls through the real builders, without model execution.
2
+
3
+ python -m annoscript.preview annoscript_examples/request_preview.decision.json -o /tmp/decision.json
4
+ No Python program is imported, no credential file is read, and no HTTP is sent.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ from annoscript._data import snapshot
13
+ from annoscript._request_adapters import DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER
14
+ from annoscript.backends import DecisionAPIBackend, LLMAPIBackend, InferenceContext, InferenceError
15
+ from annoscript.backends.base import normalize_call, DECISION_OPERATIONS, LLM_OPERATIONS
16
+ from annoscript.job_protocol import decode
17
+ from annoscript.remote import RemoteProtocolError
18
+
19
+
20
+ def preview_request(spec, *, split_batches=False, max_calls=32):
21
+ """Preview one physical request from explicitly resolved calls and input.
22
+
23
+ Generation batch max_tokens is the already selected shared budget. For a
24
+ single call max_tokens, when present, caps its normal effective budget.
25
+ This does not execute IR, guess branch results, or select a batch.
26
+ """
27
+ spec = snapshot(spec)
28
+ required = {"format", "version", "stage", "model", "state", "calls"}
29
+ optional = {"system_prompt", "request_adapter", "request_adapter_options", "options", "mode", "max_tokens", "repair_feedback"}
30
+ if (type(spec) is not dict or not required <= set(spec) or set(spec) - required - optional
31
+ or spec["format"] != "lev-request-spec" or type(spec["version"]) is not int or spec["version"] != 1):
32
+ raise ValueError("expected lev-request-spec version 1 with resolved calls")
33
+ if type(spec["stage"]) is not str or spec["stage"] not in ("decision", "generation"):
34
+ raise ValueError("stage must be decision or generation")
35
+ if "request_adapter_options" in spec and type(spec["request_adapter_options"]) is not dict:
36
+ raise ValueError("request_adapter_options must be an object")
37
+ context = InferenceContext(spec["state"], spec.get("system_prompt"))
38
+ items = spec["calls"]
39
+ if type(items) is not list or not items:
40
+ raise ValueError("calls must be a nonempty list")
41
+ calls, ids = [], set()
42
+ for item in items:
43
+ if (type(item) is not dict or set(item) != {"id", "operation", "arguments"}
44
+ or type(item["id"]) is not str or not item["id"].strip() or item["id"] in ids
45
+ or type(item["operation"]) is not str or type(item["arguments"]) is not dict):
46
+ raise ValueError("calls require distinct nonblank ids, operation and arguments")
47
+ ids.add(item["id"])
48
+ operations = DECISION_OPERATIONS if spec["stage"] == "decision" else LLM_OPERATIONS
49
+ if item["operation"] not in operations:
50
+ raise ValueError("operation does not belong to the selected stage")
51
+ calls.append(normalize_call(item["id"], item["operation"], (), item["arguments"], context))
52
+ options = spec.get("options", {})
53
+ if type(options) is not dict:
54
+ raise ValueError("options must be an object")
55
+ if spec["stage"] == "decision":
56
+ if options or spec.get("mode", "batch") != "batch" or {"max_tokens", "repair_feedback"} & set(spec):
57
+ raise ValueError("decision preview requires batch mode without generation options")
58
+ backend = DecisionAPIBackend("http://offline.invalid/decision", "unused-preview-token", spec["model"],
59
+ request_adapter=spec.get("request_adapter", DEFAULT_DECISION_ADAPTER),
60
+ request_adapter_options=spec.get("request_adapter_options"))
61
+ if split_batches:
62
+ return backend.preview_batches(context, calls, max_calls=max_calls)
63
+ return backend.preview_batch(context, calls)
64
+ if split_batches or "request_adapter_options" in spec:
65
+ raise ValueError("split_batches and request_adapter_options require decision stage")
66
+ allowed = {"profile", "extract_mode", "disable_thinking", "batch_rule_dedup",
67
+ "request_policy", "assumed_avg_num_tokens_per_char"}
68
+ if set(options) - allowed:
69
+ raise ValueError("unsupported generation preview options")
70
+ mode = spec.get("mode", "single")
71
+ if mode not in ("single", "batch"):
72
+ raise ValueError("generation mode must be single or batch")
73
+ if "repair_feedback" in spec and type(spec["repair_feedback"]) is not str:
74
+ raise ValueError("repair_feedback must be a string")
75
+ if "max_tokens" in spec and (type(spec["max_tokens"]) is not int or spec["max_tokens"] < 1):
76
+ raise ValueError("max_tokens must be a positive integer")
77
+ if mode == "batch" and ("max_tokens" not in spec or "repair_feedback" in spec):
78
+ raise ValueError("batch preview needs max_tokens and cannot include repair_feedback")
79
+ if mode == "single" and len(calls) != 1:
80
+ raise ValueError("single preview requires exactly one call")
81
+ backend = LLMAPIBackend("http://offline.invalid/v1", "unused-preview-token", spec["model"],
82
+ request_adapter=spec.get("request_adapter", DEFAULT_GENERATION_ADAPTER), **options)
83
+ if mode == "batch":
84
+ return backend.preview_batch(context, calls, max_tokens=spec["max_tokens"])
85
+ return backend.preview(context, calls[0], repair_feedback=spec.get("repair_feedback"),
86
+ max_tokens_override=spec.get("max_tokens"))
87
+
88
+
89
+ def main(argv=None):
90
+ parser = argparse.ArgumentParser(prog="python -m annoscript.preview", description=__doc__)
91
+ parser.add_argument("path", help="resolved lev-request-spec JSON, or - for stdin")
92
+ parser.add_argument("-o", "--output", help="write preview JSON to a different file")
93
+ parser.add_argument("--split-batches", action="store_true", help="preview a Decision physical batch plan")
94
+ parser.add_argument("--max-calls", type=int, default=32, help="Decision questions per physical batch (with --split-batches)")
95
+ args = parser.parse_args(argv)
96
+ try:
97
+ raw = sys.stdin.read() if args.path == "-" else Path(args.path).read_bytes()
98
+ if not args.split_batches and args.max_calls != 32:
99
+ raise ValueError("--max-calls requires --split-batches")
100
+ result = preview_request(decode(raw), split_batches=args.split_batches, max_calls=args.max_calls)
101
+ output = json.dumps(result, ensure_ascii=False, allow_nan=False, indent=2) + "\n"
102
+ if args.output and args.output != "-":
103
+ if args.path != "-" and Path(args.path).resolve() == Path(args.output).resolve():
104
+ raise ValueError("output path must differ from the input specification")
105
+ Path(args.output).write_text(output, encoding="utf-8")
106
+ else:
107
+ sys.stdout.write(output)
108
+ except (OSError, UnicodeError, ValueError, TypeError, RecursionError, RemoteProtocolError, InferenceError) as exc:
109
+ print(f"annoscript.preview: {exc}", file=sys.stderr)
110
+ return 2
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
annoscript/remote.py ADDED
@@ -0,0 +1,304 @@
1
+ """Python 3.10+ synchronous IR-over-HTTP client; no local execution imports.
2
+
3
+ The wire contract is documented in remote_protocol.md. A stream owns its HTTP
4
+ response; the client owns the connection pool. Neither layer retries requests.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+
13
+ from annoscript._client_common import Result, ResultError, ResultStream, prepare_inputs
14
+ from annoscript._data import snapshot
15
+ from annoscript.compiler import compile_program
16
+
17
+
18
+ PROTOCOL_VERSION = 1
19
+
20
+
21
+ class RemoteSubmissionError(RuntimeError):
22
+ """The HTTP service rejected or aborted a submission."""
23
+
24
+ def __init__(self, code, message, *, http_status=None, retry_after=None):
25
+ super().__init__(message)
26
+ self.code = code
27
+ self.http_status = http_status
28
+ self.retry_after = retry_after
29
+
30
+
31
+ class RemoteTransportError(RuntimeError):
32
+ """Network failure; remote work may already have started or completed."""
33
+
34
+
35
+ class RemoteProtocolError(RuntimeError):
36
+ """Malformed, mismatched or incomplete remote results."""
37
+
38
+
39
+ def ir_sha256(ir):
40
+ """Hash the complete wire IR, including diagnostic source coordinates."""
41
+ encoded = json.dumps(ir, sort_keys=True, ensure_ascii=False, allow_nan=False,
42
+ separators=(",", ":")).encode("utf-8")
43
+ return hashlib.sha256(encoded).hexdigest()
44
+
45
+
46
+ def _json(text):
47
+ def pairs(items):
48
+ result = {}
49
+ for key, value in items:
50
+ if key in result:
51
+ raise ValueError("duplicate JSON key")
52
+ result[key] = value
53
+ return result
54
+
55
+ def constant(value):
56
+ raise ValueError("non-finite JSON number")
57
+
58
+ try:
59
+ value = json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
60
+ return snapshot(value)
61
+ except (ValueError, UnicodeError, RecursionError):
62
+ raise RemoteProtocolError("response must contain finite UTF-8 JSON without duplicate keys") from None
63
+
64
+
65
+ def _error(value):
66
+ if (type(value) is not dict or set(value) != {"code", "message"}
67
+ or type(value["code"]) is not str or not value["code"]
68
+ or type(value["message"]) is not str):
69
+ raise RemoteProtocolError("error must contain a nonempty code and a string message")
70
+ return ResultError(**value)
71
+
72
+
73
+ class AnnoRemoteClient:
74
+ """Compile and optimize locally, then submit IR to a remote executor.
75
+
76
+ Construction does not open connections or read credentials. ``timeout`` is
77
+ a positive HTTP phase timeout, not a total execution deadline. ``transport``
78
+ accepts an httpx synchronous transport, primarily for offline tests.
79
+
80
+ Client/stream lifecycle and iteration are single-threaded. Use separate
81
+ clients for independent worker threads. Close streams when stopping early.
82
+ """
83
+
84
+ def __init__(self, base_url, auth_token=None, *, mode="eager", optimize=True,
85
+ timeout=300.0, transport=None):
86
+ import httpx
87
+
88
+ url = httpx.URL(base_url)
89
+ if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
90
+ raise ValueError("base_url must be an HTTP(S) URL without credentials, query or fragment")
91
+ if auth_token is not None and (type(auth_token) is not str or not auth_token.strip()
92
+ or "\r" in auth_token or "\n" in auth_token):
93
+ raise ValueError("auth_token must be a nonempty single-line string or None")
94
+ if mode not in ("eager", "strict", "speculative"):
95
+ raise ValueError("mode must be eager, strict or speculative")
96
+ if type(optimize) is not bool:
97
+ raise TypeError("optimize must be a boolean")
98
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
99
+ raise ValueError("timeout must be finite and positive")
100
+ self.base_url = str(url).rstrip("/")
101
+ self.mode, self.optimize, self.timeout = mode, optimize, timeout
102
+ self._token, self._transport = auth_token, transport
103
+ self._http = None
104
+ self._streams = set()
105
+ self.closed = False
106
+
107
+ def __enter__(self):
108
+ if self.closed:
109
+ raise RuntimeError("client is closed")
110
+ return self
111
+
112
+ def __exit__(self, *args):
113
+ self.close()
114
+
115
+ def close(self):
116
+ if self.closed:
117
+ return
118
+ self.closed = True
119
+ failure = None
120
+ for stream in list(self._streams):
121
+ try:
122
+ stream.close()
123
+ except BaseException as exc:
124
+ failure = failure or exc
125
+ if self._http is not None:
126
+ try:
127
+ self._http.close()
128
+ except BaseException as exc:
129
+ failure = failure or exc
130
+ if failure is not None:
131
+ raise failure
132
+
133
+ def _start(self):
134
+ import httpx
135
+
136
+ if self._http is None:
137
+ headers = {"Accept": "application/x-ndjson", "Content-Type": "application/json"}
138
+ if self._token is not None:
139
+ headers["Authorization"] = (self._token if self._token.lower().startswith("bearer ")
140
+ else "Bearer " + self._token)
141
+ self._http = httpx.Client(headers=headers, timeout=self.timeout,
142
+ follow_redirects=False,
143
+ transport=self._transport if self._transport is not None
144
+ else httpx.HTTPTransport(retries=0))
145
+
146
+ def submit(self, program, inputs, *, ordered=False):
147
+ """Return indexed results, including local invalid_input failures.
148
+
149
+ All checks and compilation finish before HTTP dispatch. No valid inputs
150
+ means no HTTP work. Compiler/checker defects abort submission. Remote
151
+ failures can occur during submit or iteration; previously yielded items
152
+ remain delivered. A normal exhausted stream has one result per input.
153
+ """
154
+ import httpx
155
+
156
+ if self.closed:
157
+ raise RuntimeError("client is closed")
158
+ if type(ordered) is not bool:
159
+ raise TypeError("ordered must be a boolean")
160
+ valid, errors = prepare_inputs(program, inputs)
161
+ ir = compile_program(program, optimize=self.optimize).to_dict()
162
+ digest = ir_sha256(ir)
163
+ response = None
164
+ if valid:
165
+ payload = {"protocol_version": PROTOCOL_VERSION, "ir": ir, "ir_sha256": digest,
166
+ "mode": self.mode,
167
+ "inputs": [{"index": index, "state": state} for index, state in valid]}
168
+ content = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
169
+ self._start()
170
+ try:
171
+ request = self._http.build_request("POST", self.base_url + "/v1/runs:batch", content=content)
172
+ response = self._http.send(request, stream=True)
173
+ if response.status_code != 200:
174
+ response.read()
175
+ code, message = "http_error", f"remote HTTP error {response.status_code}"
176
+ try:
177
+ data = _json(response.content)
178
+ error = _error(data.get("error")) if type(data) is dict else None
179
+ if error is not None:
180
+ code, message = error.code, error.message
181
+ except RemoteProtocolError:
182
+ pass
183
+ raise RemoteSubmissionError(code, message, http_status=response.status_code,
184
+ retry_after=response.headers.get("Retry-After"))
185
+ media_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
186
+ if media_type != "application/x-ndjson":
187
+ raise RemoteProtocolError("expected application/x-ndjson response")
188
+ except BaseException as exc:
189
+ if response is not None:
190
+ response.close()
191
+ if isinstance(exc, httpx.RequestError):
192
+ raise RemoteTransportError("remote HTTP request failed") from exc
193
+ raise
194
+ stream = _RemoteStream(self, response, digest, {i for i, _ in valid}, errors, ordered)
195
+ self._streams.add(stream)
196
+ return stream
197
+
198
+
199
+ class _RemoteStream(ResultStream):
200
+ def __init__(self, client, response, digest, expected, errors, ordered):
201
+ super().__init__()
202
+ self.client, self.response, self.digest = client, response, digest
203
+ self.expected, self.seen = expected, set()
204
+ self.local = iter(Result(i, "error", error=e) for i, e in sorted(errors.items()))
205
+ self.ordered, self.index, self.buffer = ordered, 0, {}
206
+ self.accepted = False
207
+ self.frames = self._frames() if response is not None else iter(())
208
+
209
+ def _frames(self):
210
+ # Split bytes before decoding so malformed UTF-8 is rejected rather than
211
+ # replaced by httpx's text decoder. Require newline-terminated NDJSON.
212
+ pending = bytearray()
213
+ for chunk in self.response.iter_bytes():
214
+ pending.extend(chunk)
215
+ while True:
216
+ newline = pending.find(b"\n")
217
+ if newline < 0:
218
+ break
219
+ line = bytes(pending[:newline])
220
+ del pending[:newline + 1]
221
+ if line.endswith(b"\r"):
222
+ line = line[:-1]
223
+ try:
224
+ yield _json(line.decode("utf-8"))
225
+ except UnicodeError:
226
+ raise RemoteProtocolError("response is not UTF-8") from None
227
+ if pending:
228
+ raise RemoteProtocolError("unterminated NDJSON frame")
229
+
230
+ def _remote_result(self):
231
+ import httpx
232
+
233
+ try:
234
+ for frame in self.frames:
235
+ if type(frame) is not dict:
236
+ raise RemoteProtocolError("frame must be an object")
237
+ kind = frame.get("type")
238
+ if kind == "error":
239
+ if set(frame) != {"type", "error"}:
240
+ raise RemoteProtocolError("invalid error frame")
241
+ error = _error(frame["error"])
242
+ raise RemoteSubmissionError(error.code, error.message,
243
+ http_status=self.response.status_code,
244
+ retry_after=self.response.headers.get("Retry-After"))
245
+ if not self.accepted:
246
+ if (set(frame) != {"type", "protocol_version", "ir_sha256"}
247
+ or kind != "accepted" or type(frame["protocol_version"]) is not int
248
+ or frame["protocol_version"] != PROTOCOL_VERSION
249
+ or frame["ir_sha256"] != self.digest):
250
+ raise RemoteProtocolError("missing or mismatched accepted frame")
251
+ self.accepted = True
252
+ continue
253
+ if kind == "done":
254
+ if set(frame) != {"type"} or self.seen != self.expected:
255
+ raise RemoteProtocolError("done before all submitted inputs completed")
256
+ # Verify the response really ends. Do not accept extra results
257
+ # or an error after the completion marker.
258
+ end = object()
259
+ if next(self.frames, end) is not end:
260
+ raise RemoteProtocolError("frame after done")
261
+ raise StopIteration
262
+ if kind != "result" or set(frame) != {"type", "index", "status", "value", "error"}:
263
+ raise RemoteProtocolError("invalid result frame")
264
+ index = frame["index"]
265
+ if type(index) is not int or index not in self.expected or index in self.seen:
266
+ raise RemoteProtocolError("unknown or duplicate result index")
267
+ error = None if frame["error"] is None else _error(frame["error"])
268
+ try:
269
+ result = Result(index, frame["status"], frame["value"], error)
270
+ except ValueError as exc:
271
+ raise RemoteProtocolError(str(exc)) from None
272
+ self.seen.add(index)
273
+ return result
274
+ except httpx.RequestError as exc:
275
+ raise RemoteTransportError("remote result stream failed") from exc
276
+ raise RemoteProtocolError("remote result stream ended without done")
277
+
278
+ def _pull(self):
279
+ try:
280
+ return next(self.local)
281
+ except StopIteration:
282
+ if self.response is None:
283
+ raise
284
+ return self._remote_result()
285
+
286
+ def _next_result(self):
287
+ if not self.ordered:
288
+ return self._pull()
289
+ while self.index not in self.buffer:
290
+ result = self._pull()
291
+ self.buffer[result.index] = result
292
+ result = self.buffer.pop(self.index)
293
+ self.index += 1
294
+ return result
295
+
296
+ def _close(self):
297
+ try:
298
+ if self.response is not None:
299
+ self.response.close()
300
+ finally:
301
+ self.frames = iter(())
302
+ self.local = iter(())
303
+ self.buffer.clear()
304
+ self.client._streams.discard(self)