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/test_jobs.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Client-only durable job tests: deterministic HTTP, no execution imports."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import gc
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
import weakref
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from annoscript.client import AnnoClient, precondition, require
|
|
16
|
+
from annoscript.compiler import compile_program
|
|
17
|
+
from annoscript.job_protocol import cursor_for, cursor_seq, request_sha256, validate_page
|
|
18
|
+
from annoscript.jobs import (AnnoJobClient, RemoteCancellationError, RemoteJobPausedError,
|
|
19
|
+
RemoteProtocolError, RemoteSubmissionError, RemoteTransportError)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate(anno):
|
|
23
|
+
return anno.generate(anno.state, max_length=512).text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check(state):
|
|
27
|
+
require(state != "invalid", message="invalid record")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@precondition(check)
|
|
31
|
+
def checked(anno):
|
|
32
|
+
return anno.generate(anno.state, max_length=512).text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Clock:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.now, self.sleeps = 1000.0, []
|
|
38
|
+
|
|
39
|
+
def __call__(self):
|
|
40
|
+
return self.now
|
|
41
|
+
|
|
42
|
+
def sleep(self, seconds):
|
|
43
|
+
self.sleeps.append(seconds)
|
|
44
|
+
self.now += seconds
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Wire:
|
|
48
|
+
def __init__(self, *, values=None, reverse=False):
|
|
49
|
+
self.requests, self.jobs, self.keys, self.sessions = [], {}, {}, {}
|
|
50
|
+
self.values, self.reverse = values, reverse
|
|
51
|
+
self.failures, self.page_transform = [], None
|
|
52
|
+
self.lose_accept = False
|
|
53
|
+
self.paused = False
|
|
54
|
+
self.job_counter = 0
|
|
55
|
+
|
|
56
|
+
def response(self, value, status=200, headers=None):
|
|
57
|
+
return httpx.Response(status, json=value, headers=headers)
|
|
58
|
+
|
|
59
|
+
def session(self, sid):
|
|
60
|
+
return {"protocol_version": 1, "session_id": sid, "state": self.sessions[sid],
|
|
61
|
+
"expires_at": 1300.0, "heartbeat_interval": 30.0}
|
|
62
|
+
|
|
63
|
+
def __call__(self, request):
|
|
64
|
+
self.requests.append(request)
|
|
65
|
+
if self.failures:
|
|
66
|
+
failure = self.failures.pop(0)
|
|
67
|
+
if isinstance(failure, BaseException):
|
|
68
|
+
raise failure
|
|
69
|
+
if failure is not None:
|
|
70
|
+
return failure
|
|
71
|
+
path = request.url.path
|
|
72
|
+
if path == "/v1/sessions":
|
|
73
|
+
sid = request.headers["Idempotency-Key"]
|
|
74
|
+
self.sessions.setdefault(sid, "active")
|
|
75
|
+
return self.response(self.session(sid), 201)
|
|
76
|
+
if path.startswith("/v1/sessions/"):
|
|
77
|
+
sid, op = path.rsplit("/", 1)[1].split(":")
|
|
78
|
+
if op == "close":
|
|
79
|
+
self.sessions[sid] = "closed"
|
|
80
|
+
for jid in self.jobs:
|
|
81
|
+
self.jobs[jid]["job"]["state"] = "cancelled"
|
|
82
|
+
return self.response(self.session(sid))
|
|
83
|
+
if path == "/v1/jobs" and request.method == "POST":
|
|
84
|
+
payload = json.loads(request.content)
|
|
85
|
+
key = request.headers["Idempotency-Key"]
|
|
86
|
+
if key not in self.keys:
|
|
87
|
+
self.job_counter += 1
|
|
88
|
+
jid = str(self.job_counter)
|
|
89
|
+
items = payload["inputs"]
|
|
90
|
+
values = self.values if self.values is not None else [v["state"] for v in items]
|
|
91
|
+
rows = [{"index": item["index"], "status": "success", "value": value,
|
|
92
|
+
"error": None, "attempts": 1} for item, value in zip(items, values)]
|
|
93
|
+
if self.reverse:
|
|
94
|
+
rows.reverse()
|
|
95
|
+
for n, row in enumerate(rows, 1):
|
|
96
|
+
row["seq"] = n
|
|
97
|
+
job = {"job_id": jid, "ir_sha256": payload["ir_sha256"], "release_id": "fixed-release",
|
|
98
|
+
"mode": payload["mode"], "input_indices": [i["index"] for i in items],
|
|
99
|
+
"state": "paused" if self.paused else "completed",
|
|
100
|
+
"counts": {"total": len(items), "completed": 0 if self.paused else len(rows),
|
|
101
|
+
"success": 0 if self.paused else len(rows), "error": 0},
|
|
102
|
+
"pause_generation": 1 if self.paused else 0,
|
|
103
|
+
"pause_reason": {"code": "model_timeout", "message": "budget exhausted"} if self.paused else None,
|
|
104
|
+
"created_at": 1000.0, "updated_at": 1000.0}
|
|
105
|
+
self.keys[key] = jid
|
|
106
|
+
self.jobs[jid] = {"job": job, "rows": [] if self.paused else rows, "request": payload}
|
|
107
|
+
job = self.jobs[self.keys[key]]["job"]
|
|
108
|
+
if self.lose_accept:
|
|
109
|
+
self.lose_accept = False
|
|
110
|
+
raise httpx.ReadError("lost acceptance")
|
|
111
|
+
return self.response({"protocol_version": 1, "job": job}, 202)
|
|
112
|
+
if path == "/v1/jobs:lookup":
|
|
113
|
+
job = self.jobs[self.keys[request.headers["Idempotency-Key"]]]["job"]
|
|
114
|
+
return self.response({"protocol_version": 1, "job": job})
|
|
115
|
+
jid = path.split("/")[3].split(":")[0]
|
|
116
|
+
job = self.jobs[jid]["job"]
|
|
117
|
+
if path.endswith(":cancel"):
|
|
118
|
+
job["state"], job["pause_reason"] = "cancelled", None
|
|
119
|
+
job["counts"].update(completed=job["counts"]["total"], error=job["counts"]["total"], success=0)
|
|
120
|
+
return self.response({"protocol_version": 1, "job": job})
|
|
121
|
+
if path.endswith("/results"):
|
|
122
|
+
after = cursor_seq(request.url.params.get("cursor"), jid)
|
|
123
|
+
# Small pages exercise cursors and ordered buffers across pages.
|
|
124
|
+
rows = self.jobs[jid]["rows"][after:after + 1]
|
|
125
|
+
last = after + len(rows)
|
|
126
|
+
more = last < job["counts"]["completed"]
|
|
127
|
+
page = {"protocol_version": 1, "job": deepcopy(job), "results": deepcopy(rows),
|
|
128
|
+
"next_cursor": cursor_for(jid, last), "has_more": more,
|
|
129
|
+
"done": job["state"] in ("completed", "cancelled", "failed") and not more}
|
|
130
|
+
if self.page_transform:
|
|
131
|
+
self.page_transform(page)
|
|
132
|
+
return self.response(page)
|
|
133
|
+
return self.response({"protocol_version": 1, "job": job})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ClientTests(unittest.TestCase):
|
|
137
|
+
def client(self, wire=None, **kwargs):
|
|
138
|
+
wire = wire or Wire()
|
|
139
|
+
client = AnnoClient.remote("http://test", transport=httpx.MockTransport(wire), **kwargs)
|
|
140
|
+
clock = Clock()
|
|
141
|
+
low = client._remote.session.client
|
|
142
|
+
low.clock, low.wall_clock, low.sleep, low.random = clock, clock, clock.sleep, lambda: 1.0
|
|
143
|
+
self.addCleanup(client.close)
|
|
144
|
+
return client, wire, clock
|
|
145
|
+
|
|
146
|
+
def test_empty_all_invalid_and_defects_do_not_contact_server(self):
|
|
147
|
+
client, wire, _ = self.client()
|
|
148
|
+
self.assertEqual(list(client.submit(checked, [])), [])
|
|
149
|
+
result = list(client.submit(checked, ["invalid", float("nan")]))
|
|
150
|
+
self.assertEqual([r.error.code for r in result], ["invalid_input"] * 2)
|
|
151
|
+
with patch("annoscript._client_common._PRECONDITIONS", {checked: (lambda state: 1 / 0,)}):
|
|
152
|
+
with self.assertRaises(ZeroDivisionError):
|
|
153
|
+
client.submit(checked, ["a"])
|
|
154
|
+
with patch("annoscript._remote_jobs.compile_program", side_effect=ValueError("compile")):
|
|
155
|
+
with self.assertRaisesRegex(ValueError, "compile"):
|
|
156
|
+
client.submit(checked, ["a"])
|
|
157
|
+
self.assertEqual(wire.requests, [])
|
|
158
|
+
|
|
159
|
+
def test_results_indices_order_and_json_values(self):
|
|
160
|
+
for ordered in (False, True):
|
|
161
|
+
client, wire, _ = self.client(Wire(values=[None, [1, None], {"中文": True}], reverse=True))
|
|
162
|
+
result = list(client.submit(checked, ["a", "invalid", "b", "c"], ordered=ordered))
|
|
163
|
+
self.assertEqual([r.index for r in result], [0, 1, 2, 3] if ordered else [1, 3, 2, 0])
|
|
164
|
+
values = {r.index: r.value for r in result}
|
|
165
|
+
self.assertIsNone(values[0])
|
|
166
|
+
self.assertEqual(values[2], [1, None])
|
|
167
|
+
self.assertEqual(values[3], {"中文": True})
|
|
168
|
+
self.assertEqual([v["index"] for v in wire.jobs["1"]["request"]["inputs"]], [0, 2, 3])
|
|
169
|
+
|
|
170
|
+
def test_lost_acceptance_uses_identical_request_and_one_compile(self):
|
|
171
|
+
client, wire, clock = self.client()
|
|
172
|
+
wire.lose_accept = True
|
|
173
|
+
with patch("annoscript._remote_jobs.compile_program", wraps=compile_program) as compile:
|
|
174
|
+
self.assertEqual(list(client.submit(generate, ["a"]))[0].value, "a")
|
|
175
|
+
self.assertEqual(compile.call_count, 1)
|
|
176
|
+
posts = [r for r in wire.requests if r.url.path == "/v1/jobs"]
|
|
177
|
+
self.assertEqual(len(posts), 2)
|
|
178
|
+
self.assertEqual(posts[0].content, posts[1].content)
|
|
179
|
+
self.assertEqual(posts[0].headers["Idempotency-Key"], posts[1].headers["Idempotency-Key"])
|
|
180
|
+
self.assertEqual(wire.job_counter, 1)
|
|
181
|
+
self.assertTrue(clock.sleeps)
|
|
182
|
+
list(client.submit(generate, ["a"]))
|
|
183
|
+
self.assertEqual(wire.job_counter, 2)
|
|
184
|
+
|
|
185
|
+
def test_result_disconnect_does_not_duplicate_delivery(self):
|
|
186
|
+
client, wire, _ = self.client(Wire(reverse=True))
|
|
187
|
+
stream = client.submit(generate, ["a", "b", "c"])
|
|
188
|
+
first = next(stream)
|
|
189
|
+
wire.failures = [httpx.ReadError("disconnected")]
|
|
190
|
+
result = [first, *list(stream)]
|
|
191
|
+
self.assertEqual([r.index for r in result], [2, 1, 0])
|
|
192
|
+
gets = [r for r in wire.requests if r.url.path.endswith("/results")]
|
|
193
|
+
self.assertEqual(gets[1].url, gets[2].url)
|
|
194
|
+
|
|
195
|
+
def test_auth_and_protocol_errors_do_not_retry(self):
|
|
196
|
+
for response, exception in ((httpx.Response(401, json={"error": {"code": "auth", "message": "denied"}}), RemoteSubmissionError),
|
|
197
|
+
(httpx.Response(200, content=b"{bad", headers={"Content-Type": "application/json"}), RemoteProtocolError)):
|
|
198
|
+
wire = Wire()
|
|
199
|
+
wire.failures = [response]
|
|
200
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
|
|
201
|
+
with self.assertRaises(exception):
|
|
202
|
+
client.status("anything")
|
|
203
|
+
self.assertEqual(len(wire.requests), 1)
|
|
204
|
+
|
|
205
|
+
def test_rate_limit_retry_after_and_exhaustion(self):
|
|
206
|
+
wire, clock = Wire(), Clock()
|
|
207
|
+
wire.failures = [httpx.Response(429, json={"error": {"code": "limited", "message": "later"}}, headers={"Retry-After": "3"})] * 5
|
|
208
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(wire), _clock=clock,
|
|
209
|
+
_wall_clock=clock, _sleep=clock.sleep, _random=lambda: 0) as client:
|
|
210
|
+
with self.assertRaises(RemoteSubmissionError) as caught:
|
|
211
|
+
client.status("anything")
|
|
212
|
+
self.assertEqual(caught.exception.http_status, 429)
|
|
213
|
+
self.assertEqual(caught.exception.retry_after, "3")
|
|
214
|
+
self.assertEqual(clock.sleeps, [3] * 4)
|
|
215
|
+
self.assertEqual(len(wire.requests), 5)
|
|
216
|
+
|
|
217
|
+
def test_timeout_retries_are_bounded(self):
|
|
218
|
+
wire, clock = Wire(), Clock()
|
|
219
|
+
wire.failures = [httpx.ReadTimeout("timeout")] * 5
|
|
220
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(wire), _clock=clock,
|
|
221
|
+
_sleep=clock.sleep, _random=lambda: 0) as client:
|
|
222
|
+
with self.assertRaises(RemoteTransportError):
|
|
223
|
+
client.status("anything")
|
|
224
|
+
self.assertEqual(len(wire.requests), 5)
|
|
225
|
+
|
|
226
|
+
def test_total_deadline_bounds_a_transport_ignoring_phase_timeout(self):
|
|
227
|
+
started, release, finished = threading.Event(), threading.Event(), threading.Event()
|
|
228
|
+
def slow(request):
|
|
229
|
+
started.set()
|
|
230
|
+
release.wait(2)
|
|
231
|
+
finished.set()
|
|
232
|
+
raise httpx.ReadTimeout("late transport")
|
|
233
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(slow)) as client:
|
|
234
|
+
try:
|
|
235
|
+
with self.assertRaisesRegex(RemoteTransportError, "deadline"):
|
|
236
|
+
client._request("GET", "/v1/jobs/unknown", budget=0.03, attempts=1)
|
|
237
|
+
self.assertTrue(started.is_set())
|
|
238
|
+
self.assertFalse(finished.is_set())
|
|
239
|
+
finally:
|
|
240
|
+
release.set()
|
|
241
|
+
self.assertTrue(finished.wait(1))
|
|
242
|
+
|
|
243
|
+
def test_duplicate_index_across_pages_fails_after_partial_delivery(self):
|
|
244
|
+
client, wire, _ = self.client()
|
|
245
|
+
stream = client.submit(generate, ["a", "b"])
|
|
246
|
+
self.assertEqual(next(stream).index, 0)
|
|
247
|
+
wire.page_transform = lambda page: page["results"][0].update(index=0)
|
|
248
|
+
with self.assertRaises(RemoteProtocolError):
|
|
249
|
+
next(stream)
|
|
250
|
+
|
|
251
|
+
def test_lost_acceptance_exhaustion_cancels_unknown_accepted_job(self):
|
|
252
|
+
wire = Wire()
|
|
253
|
+
def disconnect(request):
|
|
254
|
+
response = wire(request)
|
|
255
|
+
if request.url.path == "/v1/jobs":
|
|
256
|
+
raise httpx.ReadError("response always lost")
|
|
257
|
+
return response
|
|
258
|
+
client = AnnoClient.remote("http://test", transport=httpx.MockTransport(disconnect))
|
|
259
|
+
client._remote.session.client.sleep = lambda _: None
|
|
260
|
+
with self.assertRaises(RemoteTransportError):
|
|
261
|
+
client.submit(generate, ["a"])
|
|
262
|
+
self.assertEqual(wire.job_counter, 1)
|
|
263
|
+
self.assertTrue(client.closed)
|
|
264
|
+
self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
|
|
265
|
+
client.close()
|
|
266
|
+
|
|
267
|
+
def test_duplicate_unknown_missing_and_changed_bindings_fail(self):
|
|
268
|
+
transforms = [lambda p: p["results"][0].update(index=99),
|
|
269
|
+
lambda p: p.update(next_cursor=cursor_for(p["job"]["job_id"], 9)),
|
|
270
|
+
lambda p: p["job"].update(release_id="different"),
|
|
271
|
+
lambda p: p["job"].update(state="running"),
|
|
272
|
+
lambda p: p.update(done=True),
|
|
273
|
+
lambda p: p["results"].append(deepcopy(p["results"][0]))]
|
|
274
|
+
for transform in transforms:
|
|
275
|
+
client, wire, _ = self.client()
|
|
276
|
+
stream = client.submit(generate, ["a", "b"])
|
|
277
|
+
wire.page_transform = transform
|
|
278
|
+
with self.assertRaises(RemoteProtocolError):
|
|
279
|
+
list(stream)
|
|
280
|
+
self.assertTrue(stream.closed)
|
|
281
|
+
|
|
282
|
+
def test_pause_is_explicit_and_closes_submission(self):
|
|
283
|
+
wire = Wire()
|
|
284
|
+
wire.paused = True
|
|
285
|
+
client, _, _ = self.client(wire)
|
|
286
|
+
stream = client.submit(generate, ["a"])
|
|
287
|
+
with self.assertRaises(RemoteJobPausedError):
|
|
288
|
+
list(stream)
|
|
289
|
+
self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
|
|
290
|
+
|
|
291
|
+
def test_one_stream_close_then_client_close(self):
|
|
292
|
+
client, wire, _ = self.client()
|
|
293
|
+
first, second = client.submit(generate, ["a"]), client.submit(generate, ["b"])
|
|
294
|
+
first.close()
|
|
295
|
+
self.assertEqual(wire.jobs["1"]["job"]["state"], "cancelled")
|
|
296
|
+
self.assertNotEqual(wire.jobs["2"]["job"]["state"], "cancelled")
|
|
297
|
+
client.close()
|
|
298
|
+
self.assertTrue(second.closed)
|
|
299
|
+
self.assertEqual(set(wire.sessions.values()), {"closed"})
|
|
300
|
+
client.close()
|
|
301
|
+
|
|
302
|
+
def test_destructor_collects_shell_even_with_live_stream_and_heartbeat(self):
|
|
303
|
+
wire = Wire()
|
|
304
|
+
client = AnnoClient.remote("http://test", transport=httpx.MockTransport(wire))
|
|
305
|
+
stream = client.submit(generate, ["a"])
|
|
306
|
+
session, ref = client._remote.session, weakref.ref(client)
|
|
307
|
+
del client
|
|
308
|
+
gc.collect()
|
|
309
|
+
self.assertIsNone(ref())
|
|
310
|
+
self.assertTrue(session.closed)
|
|
311
|
+
self.assertTrue(stream.closed)
|
|
312
|
+
self.assertEqual(set(wire.sessions.values()), {"closed"})
|
|
313
|
+
session.thread.join(1)
|
|
314
|
+
self.assertFalse(session.thread.is_alive())
|
|
315
|
+
|
|
316
|
+
def test_explicit_close_timeout_is_reported_and_business_exception_survives(self):
|
|
317
|
+
client, wire, _ = self.client()
|
|
318
|
+
stream = client.submit(generate, ["a"])
|
|
319
|
+
with patch.object(client._remote.session.client, "_close_session", side_effect=httpx.ReadTimeout("timeout")):
|
|
320
|
+
with self.assertRaises(RemoteCancellationError):
|
|
321
|
+
client.close()
|
|
322
|
+
self.assertTrue(stream.closed)
|
|
323
|
+
client, wire, _ = self.client()
|
|
324
|
+
with patch.object(client._remote, "close", side_effect=RemoteCancellationError("cleanup")):
|
|
325
|
+
with self.assertLogs("annoscript.client", level="WARNING"):
|
|
326
|
+
with self.assertRaisesRegex(ValueError, "business"):
|
|
327
|
+
with client:
|
|
328
|
+
raise ValueError("business")
|
|
329
|
+
# The patched close deliberately bypassed real resource cleanup.
|
|
330
|
+
client._remote.session.close(1)
|
|
331
|
+
|
|
332
|
+
def test_close_low_level_client_does_not_cancel_job(self):
|
|
333
|
+
wire = Wire()
|
|
334
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
|
|
335
|
+
ir = compile_program(generate)
|
|
336
|
+
job = client.submit_ir(ir, [{"index": 2, "state": "a"}], idempotency_key="stable")
|
|
337
|
+
self.assertFalse(any(r.url.path.endswith(":cancel") for r in wire.requests))
|
|
338
|
+
with AnnoJobClient("http://test", transport=httpx.MockTransport(wire)) as client:
|
|
339
|
+
self.assertEqual(client.lookup("stable"), job)
|
|
340
|
+
|
|
341
|
+
def test_input_object_order_is_part_of_request_identity(self):
|
|
342
|
+
payload = {"protocol_version": 1, "ir_sha256": "hash", "mode": "eager",
|
|
343
|
+
"inputs": [{"index": 0, "state": {"a": 1, "b": 2}}]}
|
|
344
|
+
other = deepcopy(payload)
|
|
345
|
+
other["inputs"][0]["state"] = {"b": 2, "a": 1}
|
|
346
|
+
self.assertNotEqual(request_sha256(payload), request_sha256(other))
|
|
347
|
+
|
|
348
|
+
def test_fresh_process_remote_import_isolation_and_reconnect(self):
|
|
349
|
+
code = '''
|
|
350
|
+
import importlib.abc, sys
|
|
351
|
+
class Block(importlib.abc.MetaPathFinder):
|
|
352
|
+
def find_spec(self, name, *args):
|
|
353
|
+
if name.startswith(("annoscript.runtime", "annoscript.backends", "annoscript.plan", "annoscript.server", "fastapi", "uvicorn", "xgrammar", "vllm")):
|
|
354
|
+
raise AssertionError("forbidden import: " + name)
|
|
355
|
+
sys.meta_path.insert(0, Block())
|
|
356
|
+
from annoscript.test_jobs import Wire, checked, Clock
|
|
357
|
+
from annoscript.client import AnnoClient
|
|
358
|
+
import httpx
|
|
359
|
+
w = Wire(); w.lose_accept = True
|
|
360
|
+
with AnnoClient.remote("http://test", transport=httpx.MockTransport(w)) as c:
|
|
361
|
+
c._remote.session.client.sleep = lambda _: None
|
|
362
|
+
r = list(c.submit(checked, ["a", "invalid"], ordered=True))
|
|
363
|
+
assert [v.index for v in r] == [0, 1]
|
|
364
|
+
assert r[0].value == "a" and r[1].error.code == "invalid_input"
|
|
365
|
+
assert w.job_counter == 1
|
|
366
|
+
'''
|
|
367
|
+
subprocess.run([sys.executable, "-c", code], check=True, timeout=15)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
unittest.main()
|