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.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- 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)
|