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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Offline HTTP protocol and import-boundary tests (Python 3.10+)."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from annoscript.client import precondition, require, system_prompt
|
|
14
|
+
from annoscript.compiler import CompileError, compile_program
|
|
15
|
+
from annoscript.remote import (AnnoRemoteClient, RemoteProtocolError, RemoteSubmissionError,
|
|
16
|
+
RemoteTransportError, ir_sha256)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check(state):
|
|
20
|
+
require(state != "invalid", message="invalid record")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@system_prompt("共享规则")
|
|
24
|
+
@precondition(check)
|
|
25
|
+
def checked(anno):
|
|
26
|
+
return anno.generate(anno.state, max_length=512).text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def constant(anno):
|
|
30
|
+
unused = 1
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unsupported(anno):
|
|
35
|
+
return [x for x in anno.state]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def success(index, value=None):
|
|
39
|
+
return {"type": "result", "index": index, "status": "success", "value": value, "error": None}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def accepted(payload):
|
|
43
|
+
return {"type": "accepted", "protocol_version": 1, "ir_sha256": payload["ir_sha256"]}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode(frames):
|
|
47
|
+
return b"".join((json.dumps(f, ensure_ascii=False, allow_nan=False) + "\n").encode() for f in frames)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Chunks(httpx.SyncByteStream):
|
|
51
|
+
def __init__(self, chunks):
|
|
52
|
+
self.chunks = chunks
|
|
53
|
+
self.closed = False
|
|
54
|
+
self.pulled = 0
|
|
55
|
+
|
|
56
|
+
def __iter__(self):
|
|
57
|
+
for chunk in self.chunks:
|
|
58
|
+
self.pulled += 1
|
|
59
|
+
if isinstance(chunk, Exception):
|
|
60
|
+
raise chunk
|
|
61
|
+
yield chunk
|
|
62
|
+
|
|
63
|
+
def close(self):
|
|
64
|
+
self.closed = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RemoteTests(unittest.TestCase):
|
|
68
|
+
def client(self, frames=None, **options):
|
|
69
|
+
self.requests, self.responses = [], []
|
|
70
|
+
|
|
71
|
+
def handle(request):
|
|
72
|
+
payload = json.loads(request.content)
|
|
73
|
+
self.requests.append((request, payload))
|
|
74
|
+
values = frames(payload) if frames else [accepted(payload)] + [
|
|
75
|
+
success(item["index"], item["state"]) for item in reversed(payload["inputs"])
|
|
76
|
+
] + [{"type": "done"}]
|
|
77
|
+
stream = values if isinstance(values, Chunks) else Chunks([encode(values)])
|
|
78
|
+
self.responses.append(stream)
|
|
79
|
+
return httpx.Response(200, headers={"Content-Type": "application/x-ndjson"}, stream=stream)
|
|
80
|
+
|
|
81
|
+
client = AnnoRemoteClient("http://offline.test/prefix/", transport=httpx.MockTransport(handle), **options)
|
|
82
|
+
self.addCleanup(client.close)
|
|
83
|
+
return client
|
|
84
|
+
|
|
85
|
+
def test_ir_only_request_and_indexed_results(self):
|
|
86
|
+
for ordered in (False, True):
|
|
87
|
+
for mode in ("strict", "eager"):
|
|
88
|
+
with self.subTest(ordered=ordered, mode=mode), self.client(mode=mode, auth_token="test-token") as client:
|
|
89
|
+
inputs = ["甲", "invalid", {"nested": [1, None]}, float("nan"), "乙"]
|
|
90
|
+
results = list(client.submit(checked, inputs, ordered=ordered))
|
|
91
|
+
self.assertEqual([r.index for r in results], [0, 1, 2, 3, 4] if ordered else [1, 3, 4, 2, 0])
|
|
92
|
+
by_index = {r.index: r for r in results}
|
|
93
|
+
for i in (1, 3):
|
|
94
|
+
self.assertEqual(by_index[i].error.code, "invalid_input")
|
|
95
|
+
for i in (0, 2, 4):
|
|
96
|
+
self.assertEqual(by_index[i].value, inputs[i])
|
|
97
|
+
request, payload = self.requests[0]
|
|
98
|
+
self.assertEqual(request.method, "POST")
|
|
99
|
+
self.assertEqual(str(request.url), "http://offline.test/prefix/v1/runs:batch")
|
|
100
|
+
self.assertEqual(request.headers["Authorization"], "Bearer test-token")
|
|
101
|
+
self.assertEqual(set(payload), {"protocol_version", "ir", "ir_sha256", "mode", "inputs"})
|
|
102
|
+
self.assertEqual(payload["mode"], mode)
|
|
103
|
+
self.assertEqual(payload["ir"], compile_program(checked).to_dict())
|
|
104
|
+
self.assertEqual(payload["ir_sha256"], ir_sha256(payload["ir"]))
|
|
105
|
+
self.assertEqual([item["index"] for item in payload["inputs"]], [0, 2, 4])
|
|
106
|
+
self.assertNotIn("invalid record", request.content.decode())
|
|
107
|
+
self.assertNotIn("test-token", request.content.decode())
|
|
108
|
+
self.assertTrue(self.responses[0].closed)
|
|
109
|
+
|
|
110
|
+
def test_empty_invalid_only_and_checker_defect_make_no_http_calls(self):
|
|
111
|
+
with self.client() as client:
|
|
112
|
+
self.assertEqual(list(client.submit(checked, [])), [])
|
|
113
|
+
self.assertEqual([r.error.code for r in client.submit(checked, ["invalid", 1, {1: "bad"}])],
|
|
114
|
+
["invalid_input"] * 3)
|
|
115
|
+
for bad in (lambda state: 42, lambda state: 1 / 0):
|
|
116
|
+
with patch("annoscript._client_common._PRECONDITIONS", {checked: (bad,)}):
|
|
117
|
+
with self.assertRaises((TypeError, ZeroDivisionError)):
|
|
118
|
+
client.submit(checked, ["first", "second"])
|
|
119
|
+
with self.assertRaises(CompileError):
|
|
120
|
+
client.submit(unsupported, ["first"])
|
|
121
|
+
self.assertEqual(self.requests, [])
|
|
122
|
+
self.assertIsNone(client._http)
|
|
123
|
+
|
|
124
|
+
def test_snapshot_and_optimize_switch(self):
|
|
125
|
+
for optimize in (False, True):
|
|
126
|
+
with self.client(optimize=optimize) as client:
|
|
127
|
+
inputs = [{"items": ["original"]}]
|
|
128
|
+
before = deepcopy(inputs)
|
|
129
|
+
stream = client.submit(constant, inputs)
|
|
130
|
+
inputs[0]["items"].append("changed")
|
|
131
|
+
self.assertEqual(list(stream)[0].value, before[0])
|
|
132
|
+
payload = self.requests[0][1]
|
|
133
|
+
self.assertEqual(payload["inputs"][0]["state"], before[0])
|
|
134
|
+
self.assertEqual(payload["ir"], compile_program(constant, optimize=optimize).to_dict())
|
|
135
|
+
self.assertNotEqual(compile_program(constant, optimize=False).to_json(), compile_program(constant).to_json())
|
|
136
|
+
|
|
137
|
+
def test_values_and_per_item_errors(self):
|
|
138
|
+
def frames(payload):
|
|
139
|
+
return [accepted(payload), success(2, [None, {"中文": 1}]),
|
|
140
|
+
{"type": "result", "index": 1, "status": "error", "value": None,
|
|
141
|
+
"error": {"code": "constraint_failed", "message": "failed"}},
|
|
142
|
+
success(0), {"type": "done"}]
|
|
143
|
+
with self.client(frames) as client:
|
|
144
|
+
results = list(client.submit(constant, ["a", "b", "c"], ordered=True))
|
|
145
|
+
self.assertEqual(results[0].status, "success")
|
|
146
|
+
self.assertIsNone(results[0].value)
|
|
147
|
+
self.assertEqual(results[1].error.code, "constraint_failed")
|
|
148
|
+
self.assertEqual(results[2].value, [None, {"中文": 1}])
|
|
149
|
+
|
|
150
|
+
def test_protocol_rejects_mismatches_duplicates_missing_and_malformed_results(self):
|
|
151
|
+
mutations = [
|
|
152
|
+
lambda p: [],
|
|
153
|
+
lambda p: [success(0), {"type": "done"}],
|
|
154
|
+
lambda p: [{**accepted(p), "protocol_version": True}],
|
|
155
|
+
lambda p: [{**accepted(p), "protocol_version": 2}],
|
|
156
|
+
lambda p: [{**accepted(p), "ir_sha256": "wrong"}],
|
|
157
|
+
lambda p: [accepted(p), accepted(p)],
|
|
158
|
+
lambda p: [accepted(p), success(0), success(0), {"type": "done"}],
|
|
159
|
+
lambda p: [accepted(p), success(4)],
|
|
160
|
+
lambda p: [accepted(p), success(True)],
|
|
161
|
+
lambda p: [accepted(p), {"type": "done"}],
|
|
162
|
+
lambda p: [accepted(p), success(0)],
|
|
163
|
+
lambda p: [accepted(p), {"type": "result", "index": 0, "status": "success"}],
|
|
164
|
+
lambda p: [accepted(p), {**success(0), "status": "unknown"}],
|
|
165
|
+
lambda p: [accepted(p), {**success(0), "error": {"code": "x", "message": "bad"}}],
|
|
166
|
+
lambda p: [accepted(p), {**success(0), "status": "error"}],
|
|
167
|
+
lambda p: [accepted(p), success(0), {"type": "done"}, success(0)],
|
|
168
|
+
lambda p: [accepted(p), success(0), {"type": "done"}, None],
|
|
169
|
+
]
|
|
170
|
+
for frames in mutations:
|
|
171
|
+
for ordered in (False, True):
|
|
172
|
+
with self.subTest(frames=mutations.index(frames), ordered=ordered), self.client(frames) as client:
|
|
173
|
+
stream = client.submit(constant, ["a"], ordered=ordered)
|
|
174
|
+
with self.assertRaises(RemoteProtocolError):
|
|
175
|
+
list(stream)
|
|
176
|
+
self.assertTrue(stream.closed)
|
|
177
|
+
self.assertTrue(self.responses[0].closed)
|
|
178
|
+
self.assertEqual(len(self.requests), 1)
|
|
179
|
+
|
|
180
|
+
def test_json_and_utf8_errors_and_chunk_boundaries(self):
|
|
181
|
+
invalid = [b"{}", b"\n", b"not json\n", b"\xff\n", b"[]\n",
|
|
182
|
+
b'{"type":"done","type":"done"}\n', b'{"x":NaN}\n',
|
|
183
|
+
b'{"x":1e999}\n']
|
|
184
|
+
for data in invalid:
|
|
185
|
+
with self.subTest(data=data), self.client(lambda p: Chunks([encode([accepted(p)]), data])) as client:
|
|
186
|
+
with self.assertRaises(RemoteProtocolError):
|
|
187
|
+
list(client.submit(constant, ["a"]))
|
|
188
|
+
def bytes_one_at_a_time(p):
|
|
189
|
+
data = encode([accepted(p), success(0, "中文"), {"type": "done"}]).replace(b"\n", b"\r\n")
|
|
190
|
+
return Chunks([data[i:i+1] for i in range(len(data))])
|
|
191
|
+
with self.client(bytes_one_at_a_time) as client:
|
|
192
|
+
self.assertEqual(list(client.submit(constant, ["a"]))[0].value, "中文")
|
|
193
|
+
|
|
194
|
+
def test_http_rejection_and_submission_error_preserve_metadata(self):
|
|
195
|
+
for status in (302, 401, 403, 429, 503):
|
|
196
|
+
requests = []
|
|
197
|
+
def handle(request):
|
|
198
|
+
requests.append(request)
|
|
199
|
+
return httpx.Response(status, headers={"Retry-After": "12", "Location": "http://elsewhere.test/"},
|
|
200
|
+
json={"error": {"code": "rejected", "message": "try later"}})
|
|
201
|
+
with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(handle)) as client:
|
|
202
|
+
with self.assertRaises(RemoteSubmissionError) as caught:
|
|
203
|
+
client.submit(constant, ["a"])
|
|
204
|
+
self.assertEqual((caught.exception.code, caught.exception.http_status, caught.exception.retry_after),
|
|
205
|
+
("rejected", status, "12"))
|
|
206
|
+
self.assertEqual(len(requests), 1)
|
|
207
|
+
with self.client(lambda p: [accepted(p), success(0), {"type": "error", "error": {
|
|
208
|
+
"code": "backend_unavailable", "message": "unavailable"}}]) as client:
|
|
209
|
+
stream = client.submit(constant, ["a", "b"])
|
|
210
|
+
self.assertEqual(next(stream).index, 0)
|
|
211
|
+
with self.assertRaises(RemoteSubmissionError) as caught:
|
|
212
|
+
next(stream)
|
|
213
|
+
self.assertEqual(caught.exception.code, "backend_unavailable")
|
|
214
|
+
self.assertTrue(stream.closed)
|
|
215
|
+
|
|
216
|
+
def test_transport_failures_are_not_retried(self):
|
|
217
|
+
for error_type in (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ReadError):
|
|
218
|
+
calls = []
|
|
219
|
+
def handle(request):
|
|
220
|
+
calls.append(request)
|
|
221
|
+
raise error_type("injected")
|
|
222
|
+
with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(handle)) as client:
|
|
223
|
+
with self.assertRaises(RemoteTransportError):
|
|
224
|
+
client.submit(constant, ["a"])
|
|
225
|
+
self.assertEqual(len(calls), 1)
|
|
226
|
+
with self.client(lambda p: Chunks([encode([accepted(p), success(0)]), error_type("injected")])) as client:
|
|
227
|
+
stream = client.submit(constant, ["a", "b"])
|
|
228
|
+
self.assertEqual(next(stream).index, 0)
|
|
229
|
+
with self.assertRaises(RemoteTransportError):
|
|
230
|
+
next(stream)
|
|
231
|
+
self.assertTrue(stream.closed)
|
|
232
|
+
self.assertEqual(len(self.requests), 1)
|
|
233
|
+
|
|
234
|
+
def test_response_headers_and_errors_before_acceptance(self):
|
|
235
|
+
for media_type in (None, "application/json", "text/plain"):
|
|
236
|
+
response = httpx.Response(200, headers={} if media_type is None else {"Content-Type": media_type},
|
|
237
|
+
stream=Chunks([b'{}\n']))
|
|
238
|
+
with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(lambda r: response)) as client:
|
|
239
|
+
with self.assertRaises(RemoteProtocolError):
|
|
240
|
+
client.submit(constant, ["a"])
|
|
241
|
+
self.assertTrue(response.is_closed)
|
|
242
|
+
with self.client(lambda p: [{"type": "error", "error": {"code": "unsupported_ir", "message": "version"}}]) as client:
|
|
243
|
+
with self.assertRaises(RemoteSubmissionError) as caught:
|
|
244
|
+
list(client.submit(constant, ["a"]))
|
|
245
|
+
self.assertEqual(caught.exception.code, "unsupported_ir")
|
|
246
|
+
response = httpx.Response(503, headers={"Retry-After": "10"}, content="<html>private debug details</html>")
|
|
247
|
+
with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(lambda r: response)) as client:
|
|
248
|
+
with self.assertRaises(RemoteSubmissionError) as caught:
|
|
249
|
+
client.submit(constant, ["a"])
|
|
250
|
+
self.assertEqual(caught.exception.http_status, 503)
|
|
251
|
+
self.assertEqual(caught.exception.retry_after, "10")
|
|
252
|
+
self.assertNotIn("private", str(caught.exception))
|
|
253
|
+
|
|
254
|
+
def test_close_is_lazy_idempotent_and_client_closes_all_streams(self):
|
|
255
|
+
def frames(p):
|
|
256
|
+
return Chunks([encode([accepted(p)]), encode([success(0)]),
|
|
257
|
+
encode([success(1)]), encode([{"type": "done"}])])
|
|
258
|
+
with self.client(frames) as client:
|
|
259
|
+
a = client.submit(constant, ["a", "b"])
|
|
260
|
+
self.assertEqual(self.responses[0].pulled, 0)
|
|
261
|
+
self.assertEqual(next(a).index, 0)
|
|
262
|
+
self.assertEqual(self.responses[0].pulled, 2)
|
|
263
|
+
a.close()
|
|
264
|
+
a.close()
|
|
265
|
+
self.assertEqual(self.responses[0].pulled, 2)
|
|
266
|
+
b = client.submit(constant, ["a", "b"])
|
|
267
|
+
client.close()
|
|
268
|
+
self.assertTrue(b.closed)
|
|
269
|
+
self.assertEqual(list(a), [])
|
|
270
|
+
self.assertEqual(list(b), [])
|
|
271
|
+
self.assertTrue(all(response.closed for response in self.responses))
|
|
272
|
+
with self.assertRaises(RuntimeError):
|
|
273
|
+
client.submit(constant, [])
|
|
274
|
+
|
|
275
|
+
def test_cleanup_failure_still_closes_other_streams(self):
|
|
276
|
+
client = self.client()
|
|
277
|
+
streams = [client.submit(constant, ["a"]) for _ in range(2)]
|
|
278
|
+
with patch.object(self.responses[0], "close", side_effect=RuntimeError("cleanup failed")):
|
|
279
|
+
with self.assertRaisesRegex(RuntimeError, "cleanup failed"):
|
|
280
|
+
client.close()
|
|
281
|
+
self.assertTrue(all(stream.closed for stream in streams))
|
|
282
|
+
self.assertTrue(self.responses[1].closed)
|
|
283
|
+
self.assertTrue(client._http.is_closed)
|
|
284
|
+
|
|
285
|
+
def test_input_and_configuration_types(self):
|
|
286
|
+
for options in ({"mode": "other"}, {"optimize": 1}, {"timeout": 0}, {"timeout": True},
|
|
287
|
+
{"timeout": float("inf")}, {"auth_token": "a\nb"}):
|
|
288
|
+
with self.subTest(options=options), self.assertRaises((ValueError, TypeError)):
|
|
289
|
+
AnnoRemoteClient("http://offline.test", **options)
|
|
290
|
+
for url in ("file:///tmp/x", "http://u:p@host", "http://host/?token=x", "http://host/#x"):
|
|
291
|
+
with self.assertRaises(ValueError):
|
|
292
|
+
AnnoRemoteClient(url)
|
|
293
|
+
with self.client() as client:
|
|
294
|
+
with self.assertRaises(TypeError):
|
|
295
|
+
client.submit(constant, ("a",))
|
|
296
|
+
with self.assertRaises(TypeError):
|
|
297
|
+
client.submit(constant, ["a"], ordered=1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ImportBoundaryTests(unittest.TestCase):
|
|
301
|
+
def run_isolated(self, code):
|
|
302
|
+
result = subprocess.run([sys.executable, "-B", "-c", code], capture_output=True, text=True,
|
|
303
|
+
cwd=Path(__file__).resolve().parents[1], timeout=30)
|
|
304
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
unittest.main()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Frozen pre-refactor wire compatibility, explicit binding and offline preview."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import hashlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from annoscript._data import thaw
|
|
16
|
+
from annoscript._request_adapters import DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER
|
|
17
|
+
from annoscript.backends import DecisionAPIBackend, LLMAPIBackend, InferenceContext, InferenceError
|
|
18
|
+
from annoscript.backends._batch_wire import batch_calls
|
|
19
|
+
from annoscript.backends.base import normalize_call, encode_payload
|
|
20
|
+
from annoscript.backends.config import backends_from_env
|
|
21
|
+
from annoscript.client import AnnoClient
|
|
22
|
+
from annoscript.preview import preview_request, main
|
|
23
|
+
from annoscript.server.releases import Releases, manifest
|
|
24
|
+
from annoscript.server.worker import backends as worker_backends
|
|
25
|
+
|
|
26
|
+
BASELINE = Path(__file__).with_name('fixtures') / 'request-adapters-v1.json'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def configuration():
|
|
30
|
+
return {'decision': {'base_url': 'http://offline.invalid/decision', 'model': 'unchanged-decider', 'revision': 'fixed'},
|
|
31
|
+
'generation': {'base_url': 'http://offline.invalid/v1', 'model': 'unchanged-generator', 'revision': 'fixed'}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def spec(stage='generation', **extra):
|
|
35
|
+
value = {'format': 'lev-request-spec', 'version': 1, 'stage': stage, 'model': 'unchanged-model',
|
|
36
|
+
'state': {'text': '北京广州'}, 'calls': [{'id': 'x', 'operation': 'generate',
|
|
37
|
+
'arguments': {'instructions': '标题', 'max_length': 24}}]}
|
|
38
|
+
if stage == 'decision':
|
|
39
|
+
value['calls'][0].update(operation='choice', arguments={'instructions': '类别', 'criteria': {'甲': '甲类', '乙': '乙类'}})
|
|
40
|
+
value.update(extra)
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def answer(call):
|
|
45
|
+
if call.operation == 'generate':
|
|
46
|
+
return '标题'
|
|
47
|
+
if call.operation == 'generate_list':
|
|
48
|
+
return ['维度']
|
|
49
|
+
return ['北京']
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FrozenWireTests(unittest.IsolatedAsyncioTestCase):
|
|
53
|
+
async def test_111_pre_refactor_bodies_default_and_explicit_on_real_send_path(self):
|
|
54
|
+
fixture = json.loads(BASELINE.read_text())
|
|
55
|
+
self.assertEqual(fixture['source_head'], 'synthetic-request-adapter-v1')
|
|
56
|
+
self.assertEqual(len(fixture['cases']), 111)
|
|
57
|
+
for index, case in enumerate(fixture['cases']):
|
|
58
|
+
for explicit in (False, True):
|
|
59
|
+
with self.subTest(case=index, explicit=explicit):
|
|
60
|
+
context = InferenceContext(case['state'], case.get('system_prompt'))
|
|
61
|
+
calls = [normalize_call(x['id'], x['operation'], (), x['kwargs'], context) for x in case['calls']]
|
|
62
|
+
original = [thaw(c.arguments) for c in calls]
|
|
63
|
+
received = []
|
|
64
|
+
|
|
65
|
+
def handle(request):
|
|
66
|
+
received.append(request.content)
|
|
67
|
+
body = json.loads(request.content)
|
|
68
|
+
if case['stage'] == 'decision':
|
|
69
|
+
answers = {}
|
|
70
|
+
for key, q in body['questions'].items():
|
|
71
|
+
value = {'type': q['type']}
|
|
72
|
+
if q['type'] == 'noul':
|
|
73
|
+
value['noul'] = .8
|
|
74
|
+
elif q['type'] == 'choice':
|
|
75
|
+
labels = list(q['criteria'])
|
|
76
|
+
value.update(choice=labels[0], confidence=1., probabilities={k: float(k == labels[0]) for k in labels})
|
|
77
|
+
else:
|
|
78
|
+
value.update(score=1., confidence=1., probabilities={'0': 0., '1': 1.},
|
|
79
|
+
legend={str(i): v for i, v in enumerate(q['criteria'])})
|
|
80
|
+
answers[key] = value
|
|
81
|
+
data = {'answers': answers}
|
|
82
|
+
elif case['mode'] == 'batch':
|
|
83
|
+
text = json.dumps({'answers': {k: answer(c) for k, c in batch_calls(calls).items()}}, ensure_ascii=False)
|
|
84
|
+
data = {'choices': [{'finish_reason': 'stop', 'message': {'content': text}}]}
|
|
85
|
+
else:
|
|
86
|
+
value = answer(calls[0])
|
|
87
|
+
text = (json.dumps(value, ensure_ascii=False) if calls[0].arguments.get('nullable') else value) if calls[0].operation == 'generate' else json.dumps({'items': value}, ensure_ascii=False)
|
|
88
|
+
data = {'choices': [{'finish_reason': 'stop', 'message': {'content': text}}]}
|
|
89
|
+
return httpx.Response(200, json=data)
|
|
90
|
+
|
|
91
|
+
options = deepcopy(case['options'])
|
|
92
|
+
if explicit:
|
|
93
|
+
options['request_adapter'] = DEFAULT_DECISION_ADAPTER if case['stage'] == 'decision' else DEFAULT_GENERATION_ADAPTER
|
|
94
|
+
if options.get('request_policy'):
|
|
95
|
+
options['_token_counter'] = lambda payload: 10
|
|
96
|
+
cls = DecisionAPIBackend if case['stage'] == 'decision' else LLMAPIBackend
|
|
97
|
+
backend = cls('http://offline.invalid/v1', 'transport-only-secret', fixture['model'],
|
|
98
|
+
transport=httpx.MockTransport(handle), **options)
|
|
99
|
+
try:
|
|
100
|
+
if case['stage'] == 'decision':
|
|
101
|
+
preview = backend.preview_batch(context, calls)
|
|
102
|
+
await backend.infer_batch(context, calls)
|
|
103
|
+
elif case['mode'] == 'batch':
|
|
104
|
+
preview = backend.preview_batch(context, calls, max_tokens=case['max_tokens'])
|
|
105
|
+
await backend.infer_batch(context, calls, max_tokens=case['max_tokens'])
|
|
106
|
+
else:
|
|
107
|
+
# Frozen envelopes used an explicit physical budget. max_num_tokens
|
|
108
|
+
# is absent from the prompt, so pin that budget for the public path.
|
|
109
|
+
item = case['calls'][0]
|
|
110
|
+
pinned = normalize_call(item['id'], item['operation'], (),
|
|
111
|
+
{**item['kwargs'], 'max_num_tokens': case['max_tokens']}, context)
|
|
112
|
+
preview = backend.preview(context, pinned, repair_feedback=case.get('repair_feedback'))
|
|
113
|
+
await backend.infer(context, pinned, repair_feedback=case.get('repair_feedback'))
|
|
114
|
+
self.assertEqual(len(received), 1)
|
|
115
|
+
self.assertEqual(len(received[0]), case['expected_bytes'])
|
|
116
|
+
self.assertEqual(hashlib.sha256(received[0]).hexdigest(), case['expected_sha256'])
|
|
117
|
+
self.assertEqual(encode_payload(preview['payload']), received[0])
|
|
118
|
+
self.assertEqual(preview['body_sha256'], case['expected_sha256'])
|
|
119
|
+
self.assertNotIn('transport-only-secret', json.dumps(preview))
|
|
120
|
+
self.assertEqual([thaw(c.arguments) for c in calls], original)
|
|
121
|
+
finally:
|
|
122
|
+
await backend.aclose()
|
|
123
|
+
|
|
124
|
+
async def test_preview_errors_match_preflight_without_dispatch(self):
|
|
125
|
+
from annoscript.test_generation_policy import policy
|
|
126
|
+
context = InferenceContext('')
|
|
127
|
+
call = normalize_call('e', 'extract', ('来源',), {'min_items': 1, 'max_items': 2, 'max_length_per_item': 4}, context)
|
|
128
|
+
backend = LLMAPIBackend('http://offline.invalid', 'secret', 'model', profile='qwen_vllm',
|
|
129
|
+
transport=httpx.MockTransport(lambda req: self.fail('unexpected HTTP')))
|
|
130
|
+
try:
|
|
131
|
+
for invoke in (lambda: backend.preview(context, call), lambda: backend.preview_batch(context, [call], max_tokens=32)):
|
|
132
|
+
with self.assertRaises(InferenceError) as caught:
|
|
133
|
+
invoke()
|
|
134
|
+
self.assertEqual(caught.exception.code, 'unsatisfiable_constraints')
|
|
135
|
+
with self.assertRaises(InferenceError) as caught:
|
|
136
|
+
await backend.infer(context, call)
|
|
137
|
+
self.assertEqual(caught.exception.code, 'unsatisfiable_constraints')
|
|
138
|
+
self.assertIsNone(backend._client)
|
|
139
|
+
finally:
|
|
140
|
+
await backend.aclose()
|
|
141
|
+
context = InferenceContext('北京')
|
|
142
|
+
call = normalize_call('e', 'generate', ('标题',), {'max_length': 4}, context)
|
|
143
|
+
backend = LLMAPIBackend('http://offline.invalid', 'secret', 'model', profile='qwen_vllm',
|
|
144
|
+
request_policy=policy(), _token_counter=lambda p: 8190,
|
|
145
|
+
transport=httpx.MockTransport(lambda req: self.fail('unexpected HTTP')))
|
|
146
|
+
try:
|
|
147
|
+
with self.assertRaises(InferenceError) as caught:
|
|
148
|
+
backend.preview(context, call)
|
|
149
|
+
self.assertEqual(caught.exception.code, 'context_length_exceeded')
|
|
150
|
+
with self.assertRaises(InferenceError):
|
|
151
|
+
await backend.infer(context, call)
|
|
152
|
+
self.assertIsNone(backend._client)
|
|
153
|
+
finally:
|
|
154
|
+
await backend.aclose()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AdapterBindingTests(unittest.TestCase):
|
|
158
|
+
def test_release_materializes_defaults_and_rejects_unbound_or_cross_stage_recipes(self):
|
|
159
|
+
cfg = configuration()
|
|
160
|
+
implicit = manifest(cfg)
|
|
161
|
+
explicit = deepcopy(cfg)
|
|
162
|
+
explicit['decision']['request_adapter'] = DEFAULT_DECISION_ADAPTER
|
|
163
|
+
explicit['generation']['request_adapter'] = DEFAULT_GENERATION_ADAPTER
|
|
164
|
+
self.assertEqual(implicit, manifest(explicit))
|
|
165
|
+
self.assertEqual(implicit['decision']['request_adapter'], DEFAULT_DECISION_ADAPTER)
|
|
166
|
+
self.assertEqual(implicit['generation']['request_adapter'], DEFAULT_GENERATION_ADAPTER)
|
|
167
|
+
self.assertEqual(cfg, configuration())
|
|
168
|
+
for stage in ('decision', 'generation'):
|
|
169
|
+
for invalid in ('unknown', '', None, False, [], DEFAULT_GENERATION_ADAPTER if stage == 'decision' else DEFAULT_DECISION_ADAPTER):
|
|
170
|
+
altered = deepcopy(cfg)
|
|
171
|
+
altered[stage]['request_adapter'] = invalid
|
|
172
|
+
with self.subTest(stage=stage, invalid=invalid), self.assertRaises(ValueError):
|
|
173
|
+
manifest(altered)
|
|
174
|
+
|
|
175
|
+
def test_adapter_implementation_changes_release_identity_and_existing_job_stays_bound(self):
|
|
176
|
+
from annoscript.server.store import Store
|
|
177
|
+
from annoscript.test_jobs_server import payload
|
|
178
|
+
cfg = {'test': configuration()}
|
|
179
|
+
old = Releases(cfg, 'test')
|
|
180
|
+
read_bytes = Path.read_bytes
|
|
181
|
+
|
|
182
|
+
def changed_adapter(path):
|
|
183
|
+
data = read_bytes(path)
|
|
184
|
+
return data + b'\n# revision\n' if path.parent.name == 'adapters' and path.name == 'decision.py' else data
|
|
185
|
+
|
|
186
|
+
with patch.object(Path, 'read_bytes', changed_adapter):
|
|
187
|
+
new = Releases(cfg, 'test')
|
|
188
|
+
self.assertNotEqual(old.default, new.default)
|
|
189
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
190
|
+
store = Store(Path(folder) / 'jobs.sqlite')
|
|
191
|
+
first = store.submit('alice', 'same', payload(), old)
|
|
192
|
+
repeated = store.submit('alice', 'same', payload(), new)
|
|
193
|
+
fresh = store.submit('alice', 'fresh', payload(), new)
|
|
194
|
+
self.assertEqual(first['release_id'], repeated['release_id'])
|
|
195
|
+
self.assertEqual(fresh['release_id'], new.default)
|
|
196
|
+
|
|
197
|
+
def test_worker_uses_bound_adapter_despite_poisoned_environment(self):
|
|
198
|
+
cfg = manifest(configuration())
|
|
199
|
+
environment = {'ANNO_DECISION_AUTH_TOKEN': 'secret', 'ANNO_GENERATE_AUTH_TOKEN': 'secret',
|
|
200
|
+
'ANNO_DECISION_REQUEST_ADAPTER': 'unknown', 'ANNO_GENERATE_REQUEST_ADAPTER': 'unknown'}
|
|
201
|
+
with patch.dict(os.environ, environment, clear=True):
|
|
202
|
+
decision, generation = worker_backends(cfg)
|
|
203
|
+
self.assertEqual(decision.request_adapter, DEFAULT_DECISION_ADAPTER)
|
|
204
|
+
self.assertEqual(generation.request_adapter, DEFAULT_GENERATION_ADAPTER)
|
|
205
|
+
# Existing manifests without recipe fields retain the original recipe.
|
|
206
|
+
cfg['decision'].pop('request_adapter')
|
|
207
|
+
cfg['generation'].pop('request_adapter')
|
|
208
|
+
with patch.dict(os.environ, environment, clear=True):
|
|
209
|
+
decision, generation = worker_backends(cfg)
|
|
210
|
+
self.assertEqual(decision.request_adapter, DEFAULT_DECISION_ADAPTER)
|
|
211
|
+
self.assertEqual(generation.request_adapter, DEFAULT_GENERATION_ADAPTER)
|
|
212
|
+
|
|
213
|
+
def test_environment_precedence_and_client_forwarding(self):
|
|
214
|
+
env = {f'ANNO_{p}_{k}': v for p in ('DECISION', 'GENERATE') for k, v in
|
|
215
|
+
[('BASE_URL', 'http://offline.invalid'), ('AUTH_TOKEN', 'secret'), ('MODEL', 'same-model')]}
|
|
216
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
217
|
+
path = Path(folder) / 'env'
|
|
218
|
+
path.write_text('ANNO_DECISION_REQUEST_ADAPTER=unknown\nANNO_GENERATE_REQUEST_ADAPTER=unknown\n')
|
|
219
|
+
with patch.dict(os.environ, env, clear=True):
|
|
220
|
+
d, g = backends_from_env()
|
|
221
|
+
self.assertEqual((d.request_adapter, g.request_adapter), (DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER))
|
|
222
|
+
with self.assertRaises(ValueError):
|
|
223
|
+
backends_from_env(env_file=path)
|
|
224
|
+
with AnnoClient.from_env(env_file=path, decision_request_adapter=DEFAULT_DECISION_ADAPTER,
|
|
225
|
+
generation_request_adapter=DEFAULT_GENERATION_ADAPTER) as client:
|
|
226
|
+
self.assertEqual(client.runtime.decision_backend.request_adapter, DEFAULT_DECISION_ADAPTER)
|
|
227
|
+
self.assertEqual(client.runtime.llm_backend.request_adapter, DEFAULT_GENERATION_ADAPTER)
|
|
228
|
+
env.update(ANNO_DECISION_REQUEST_ADAPTER=DEFAULT_DECISION_ADAPTER, ANNO_GENERATE_REQUEST_ADAPTER=DEFAULT_GENERATION_ADAPTER)
|
|
229
|
+
with patch.dict(os.environ, env, clear=True):
|
|
230
|
+
self.assertEqual(backends_from_env(env_file=path)[1].request_adapter, DEFAULT_GENERATION_ADAPTER)
|
|
231
|
+
for value in ('', False, 0, []):
|
|
232
|
+
with self.assertRaises(ValueError):
|
|
233
|
+
backends_from_env(generation_request_adapter=value)
|
|
234
|
+
for cls, wrong in [(DecisionAPIBackend, DEFAULT_GENERATION_ADAPTER), (LLMAPIBackend, DEFAULT_DECISION_ADAPTER)]:
|
|
235
|
+
with patch('annoscript.backends.base.HTTPBackend.__init__', side_effect=AssertionError('transport initialized')):
|
|
236
|
+
with self.assertRaises(ValueError):
|
|
237
|
+
cls('http://offline.invalid', 'secret', 'model', request_adapter=wrong)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class OfflinePreviewTests(unittest.TestCase):
|
|
241
|
+
def test_preview_is_deterministic_detached_and_does_not_load_credentials_or_dispatch(self):
|
|
242
|
+
for stage in ('decision', 'generation'):
|
|
243
|
+
request = spec(stage)
|
|
244
|
+
original = deepcopy(request)
|
|
245
|
+
with patch('annoscript.backends.base.HTTPBackend.post', side_effect=AssertionError('HTTP')),\
|
|
246
|
+
patch('annoscript.backends.config.backends_from_env', side_effect=AssertionError('credentials')),\
|
|
247
|
+
patch('socket.socket.connect', side_effect=AssertionError('socket')),\
|
|
248
|
+
patch.dict(os.environ, {'ANNO_DECISION_REQUEST_ADAPTER': 'unknown', 'ANNO_GENERATE_REQUEST_ADAPTER': 'unknown'}, clear=True):
|
|
249
|
+
left, right = preview_request(request), preview_request(request)
|
|
250
|
+
self.assertEqual(left, right)
|
|
251
|
+
self.assertEqual(request, original)
|
|
252
|
+
self.assertEqual(hashlib.sha256(encode_payload(left['payload'])).hexdigest(), left['body_sha256'])
|
|
253
|
+
left['payload']['model'] = 'modified-preview'
|
|
254
|
+
self.assertEqual(preview_request(request), right)
|
|
255
|
+
|
|
256
|
+
def test_invalid_specs_and_collisions_are_rejected(self):
|
|
257
|
+
cases = [spec(version=True), spec(stage=[]), spec(options={'auth_token': 'secret'}),
|
|
258
|
+
spec(request_adapter=DEFAULT_DECISION_ADAPTER), spec(mode='single', calls=[]),
|
|
259
|
+
spec(mode='batch'), spec(max_tokens=0), spec(repair_feedback=None), spec(unrecognized=True)]
|
|
260
|
+
for change in [lambda v: v['calls'].append(deepcopy(v['calls'][0])),
|
|
261
|
+
lambda v: v['calls'][0].update(id=''),
|
|
262
|
+
lambda v: v['calls'][0].update(arguments={'instructions': 'x', 'source': 'old-api'})]:
|
|
263
|
+
v = spec(); change(v); cases.append(v)
|
|
264
|
+
collision = spec('decision')
|
|
265
|
+
collision['calls'][0]['arguments']['field_name'] = 'same'
|
|
266
|
+
other = deepcopy(collision['calls'][0]); other['id'] = 'y'
|
|
267
|
+
collision['calls'].append(other); cases.append(collision)
|
|
268
|
+
cross_stage = spec(mode='batch', max_tokens=64)
|
|
269
|
+
cross_stage['calls'] = spec('decision')['calls']
|
|
270
|
+
cases.append(cross_stage)
|
|
271
|
+
for case in cases:
|
|
272
|
+
with self.subTest(case=case), self.assertRaises((ValueError, TypeError)):
|
|
273
|
+
preview_request(case)
|
|
274
|
+
|
|
275
|
+
def test_cli_files_stdin_strict_json_and_input_protection(self):
|
|
276
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
277
|
+
source, target = Path(folder) / 'spec.json', Path(folder) / 'preview.json'
|
|
278
|
+
raw = json.dumps(spec(), ensure_ascii=False)
|
|
279
|
+
source.write_text(raw)
|
|
280
|
+
self.assertEqual(main([str(source), '-o', str(target)]), 0)
|
|
281
|
+
self.assertEqual(json.loads(target.read_text()), preview_request(spec()))
|
|
282
|
+
with patch('sys.stderr', new_callable=io.StringIO):
|
|
283
|
+
self.assertEqual(main([str(source), '-o', str(source)]), 2)
|
|
284
|
+
self.assertEqual(source.read_text(), raw)
|
|
285
|
+
with patch('sys.stdin', io.StringIO(raw)), patch('sys.stdout', new_callable=io.StringIO) as out:
|
|
286
|
+
self.assertEqual(main(['-']), 0)
|
|
287
|
+
self.assertEqual(json.loads(out.getvalue()), preview_request(spec()))
|
|
288
|
+
for raw in ['{"version":1,"version":1}', '{"x":NaN}']:
|
|
289
|
+
source.write_text(raw)
|
|
290
|
+
with patch('sys.stderr', new_callable=io.StringIO):
|
|
291
|
+
self.assertEqual(main([str(source)]), 2)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == '__main__':
|
|
295
|
+
unittest.main()
|