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,117 @@
|
|
|
1
|
+
"""Deterministic model HTTP responses; never connects to a model service."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from annoscript._test_synthetic import CannedBackend
|
|
11
|
+
from annoscript.backends import DecisionAPIBackend, LLMAPIBackend as _LLMAPIBackend
|
|
12
|
+
from annoscript.anno import GenerateResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_CAPTURES = OrderedDict()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _key(body):
|
|
19
|
+
return hashlib.sha256(json.dumps(body, ensure_ascii=False, allow_nan=False,
|
|
20
|
+
sort_keys=True, separators=(",", ":")).encode()).digest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FixtureLLMBackend(_LLMAPIBackend):
|
|
24
|
+
"""Test-only capture before transport; never add fixture metadata to the wire.
|
|
25
|
+
|
|
26
|
+
Fixed answers use the original immutable calls, not a parser for prose. Tests
|
|
27
|
+
separately assert messages, grammars and budgets on the actual HTTP requests.
|
|
28
|
+
The bounded registry keeps captures available after a runtime closes its backend.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _capture(payload, context, calls, batch):
|
|
33
|
+
key = _key(payload)
|
|
34
|
+
_CAPTURES[key] = (batch, tuple((context, call) for call in calls))
|
|
35
|
+
_CAPTURES.move_to_end(key)
|
|
36
|
+
if len(_CAPTURES) > 4096:
|
|
37
|
+
_CAPTURES.popitem(last=False)
|
|
38
|
+
return payload
|
|
39
|
+
|
|
40
|
+
def _single_payload(self, context, call, max_tokens, repair_feedback=None):
|
|
41
|
+
payload = super()._single_payload(context, call, max_tokens, repair_feedback)
|
|
42
|
+
return self._capture(payload, context, [call], False)
|
|
43
|
+
|
|
44
|
+
def _batch_payload(self, context, calls, max_tokens):
|
|
45
|
+
payload = super()._batch_payload(context, calls, max_tokens)
|
|
46
|
+
return self._capture(payload, context, calls, True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FixtureDecisionBackend(DecisionAPIBackend):
|
|
50
|
+
"""Keep original call identity and example order outside the model request."""
|
|
51
|
+
|
|
52
|
+
def _batch_payload(self, context, calls):
|
|
53
|
+
payload = super()._batch_payload(context, calls)
|
|
54
|
+
return FixtureLLMBackend._capture(payload, context, calls, True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def decision_calls(body):
|
|
58
|
+
return list(_CAPTURES[_key(body)][1])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def llm_calls(body):
|
|
62
|
+
"""Look up test-side calls by the exact request, without parsing model prose."""
|
|
63
|
+
batch, calls = _CAPTURES[_key(body)]
|
|
64
|
+
return batch, list(calls)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def llm_content(body, answer):
|
|
68
|
+
batched, calls = llm_calls(body)
|
|
69
|
+
values = [asdict(answer(context, call))["text" if call.operation == "generate" else "items"]
|
|
70
|
+
for context, call in calls]
|
|
71
|
+
if batched:
|
|
72
|
+
return json.dumps({"answers": {call.arguments.get("field_name", f"q{index}"): value
|
|
73
|
+
for index, ((_, call), value) in enumerate(zip(calls, values), 1)}},
|
|
74
|
+
ensure_ascii=False, separators=(",", ":"))
|
|
75
|
+
call = calls[0][1]
|
|
76
|
+
if call.operation == "generate":
|
|
77
|
+
return json.dumps(values[0], ensure_ascii=False) if call.arguments.get("nullable", False) else values[0]
|
|
78
|
+
return json.dumps({"items": values[0]}, ensure_ascii=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def llm_wire_id(body, call_id):
|
|
82
|
+
"""Test-side alias lookup, independent of the production mapping helper."""
|
|
83
|
+
batched, calls = llm_calls(body)
|
|
84
|
+
assert batched
|
|
85
|
+
return next(call.arguments.get("field_name", f"q{index}")
|
|
86
|
+
for index, (_, call) in enumerate(calls, 1) if call.id == call_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def http_backends(scenarios=(), *, requests=None, request_bytes=None, override=None, profile="qwen_vllm", speculative=False):
|
|
90
|
+
canned = CannedBackend(scenarios, speculative=speculative)
|
|
91
|
+
requests = requests if requests is not None else []
|
|
92
|
+
|
|
93
|
+
def handle(request):
|
|
94
|
+
body = json.loads(request.content)
|
|
95
|
+
requests.append(body)
|
|
96
|
+
if request_bytes is not None:
|
|
97
|
+
request_bytes.append(len(request.content))
|
|
98
|
+
if override is not None:
|
|
99
|
+
response = override(body)
|
|
100
|
+
if response is not None:
|
|
101
|
+
return response
|
|
102
|
+
if "questions" in body:
|
|
103
|
+
answers = {}
|
|
104
|
+
for context, call in decision_calls(body):
|
|
105
|
+
answer = canned.answer(context, call)
|
|
106
|
+
identity = call.arguments.get("field_name", call.id)
|
|
107
|
+
answers[identity] = {"type": call.operation, **asdict(answer)}
|
|
108
|
+
return httpx.Response(200, json={"answers": answers, "usage": {"input_tokens": 11, "output_tokens": 3}})
|
|
109
|
+
content = llm_content(body, lambda context, call: GenerateResponse(context.state)
|
|
110
|
+
if type(context.state) is str else canned.answer(context, call))
|
|
111
|
+
return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}],
|
|
112
|
+
"usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9,
|
|
113
|
+
"completion_tokens_details": {"reasoning_tokens": 1}}})
|
|
114
|
+
|
|
115
|
+
transport = httpx.MockTransport(handle)
|
|
116
|
+
return (FixtureDecisionBackend("http://offline.invalid", "fixture", "fixture", transport=transport),
|
|
117
|
+
FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile=profile, transport=transport))
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Test-only interpreter for serialized IR; validates, never repairs constraints.
|
|
2
|
+
|
|
3
|
+
Deliberately consumes the JSON representation, independently of compiler classes.
|
|
4
|
+
It is not a supported execution API or a production solver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import operator
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Returned(Exception):
|
|
13
|
+
def __init__(self, value):
|
|
14
|
+
self.value = value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_COMPARE = {
|
|
18
|
+
"eq": operator.eq, "ne": operator.ne, "lt": operator.lt, "le": operator.le,
|
|
19
|
+
"gt": operator.gt, "ge": operator.ge, "in": lambda a, b: a in b,
|
|
20
|
+
"not_in": lambda a, b: a not in b, "is": operator.is_, "is_not": operator.is_not,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def evaluate(program, anno):
|
|
25
|
+
"""Evaluate JSON IR against a supplied deterministic fake AnnoScript."""
|
|
26
|
+
env = {}
|
|
27
|
+
|
|
28
|
+
def expr(node):
|
|
29
|
+
kind = node["kind"]
|
|
30
|
+
if kind == "literal":
|
|
31
|
+
return node["value"]
|
|
32
|
+
if kind == "var":
|
|
33
|
+
return env[node["name"]]
|
|
34
|
+
if kind == "state":
|
|
35
|
+
return anno.state
|
|
36
|
+
if kind == "get_item":
|
|
37
|
+
return expr(node["value"])[expr(node["index"])]
|
|
38
|
+
if kind == "field":
|
|
39
|
+
return getattr(expr(node["value"]), node["name"])
|
|
40
|
+
if kind in ("list", "tuple"):
|
|
41
|
+
items = [expr(item) for item in node["items"]]
|
|
42
|
+
return items if kind == "list" else tuple(items)
|
|
43
|
+
if kind == "dict":
|
|
44
|
+
return {expr(key): expr(value) for key, value in node["entries"]}
|
|
45
|
+
if kind == "unary":
|
|
46
|
+
return {"not": operator.not_, "positive": operator.pos, "negative": operator.neg}[node["op"]](expr(node["value"]))
|
|
47
|
+
if kind == "binary":
|
|
48
|
+
assert node["op"] == "add"
|
|
49
|
+
return expr(node["left"]) + expr(node["right"])
|
|
50
|
+
if kind == "compare":
|
|
51
|
+
left = expr(node["left"])
|
|
52
|
+
for op, right_node in zip(node["ops"], node["comparators"]):
|
|
53
|
+
right = expr(right_node)
|
|
54
|
+
if not _COMPARE[op](left, right):
|
|
55
|
+
return False
|
|
56
|
+
left = right
|
|
57
|
+
return True
|
|
58
|
+
if kind == "bool_op":
|
|
59
|
+
value = expr(node["values"][0])
|
|
60
|
+
for item in node["values"][1:]:
|
|
61
|
+
if (node["op"] == "and" and not value) or (node["op"] == "or" and value):
|
|
62
|
+
break
|
|
63
|
+
value = expr(item)
|
|
64
|
+
return value
|
|
65
|
+
if kind == "intrinsic":
|
|
66
|
+
functions = {"len": len, "int": int, "range": range, "enumerate": enumerate,
|
|
67
|
+
"str.strip": str.strip, "json.dumps": json.dumps,
|
|
68
|
+
"example": lambda state, answer: deepcopy({"state": state, "answer": answer})}
|
|
69
|
+
args = [expr(arg) for arg in node["args"]]
|
|
70
|
+
kwargs = {key: expr(value) for key, value in node["kwargs"]}
|
|
71
|
+
return functions[node["name"]](*args, **kwargs)
|
|
72
|
+
if kind == "format_value":
|
|
73
|
+
value = expr(node["value"])
|
|
74
|
+
spec = expr(node["spec"]) if node["spec"] is not None else ""
|
|
75
|
+
conversion = node["conversion"]
|
|
76
|
+
if conversion != "none":
|
|
77
|
+
value = {"s": str, "r": repr, "a": ascii}[conversion](value)
|
|
78
|
+
return format(value, spec)
|
|
79
|
+
if kind == "format_string":
|
|
80
|
+
return "".join(expr(part) for part in node["parts"])
|
|
81
|
+
raise AssertionError(f"Unknown expression {kind}")
|
|
82
|
+
|
|
83
|
+
def target(node, value):
|
|
84
|
+
if node["kind"] == "name_target":
|
|
85
|
+
env[node["name"]] = value
|
|
86
|
+
else:
|
|
87
|
+
values = list(value)
|
|
88
|
+
if len(values) != len(node["items"]):
|
|
89
|
+
raise ValueError("Unpack length mismatch")
|
|
90
|
+
for item, v in zip(node["items"], values):
|
|
91
|
+
target(item, v)
|
|
92
|
+
|
|
93
|
+
def block(body):
|
|
94
|
+
for node in body:
|
|
95
|
+
kind = node["kind"]
|
|
96
|
+
if kind == "assign":
|
|
97
|
+
env[node["target"]] = expr(node["value"])
|
|
98
|
+
elif kind == "unpack":
|
|
99
|
+
target(node["target"], expr(node["value"]))
|
|
100
|
+
elif kind == "set_item":
|
|
101
|
+
value, index, item = expr(node["value"]), expr(node["index"]), expr(node["item"])
|
|
102
|
+
value[index] = item
|
|
103
|
+
elif kind == "if":
|
|
104
|
+
block(node["then_body"] if expr(node["condition"]) else node["else_body"])
|
|
105
|
+
elif kind == "for":
|
|
106
|
+
for value in expr(node["iterable"]):
|
|
107
|
+
target(node["target"], value)
|
|
108
|
+
block(node["body"])
|
|
109
|
+
elif kind == "infer":
|
|
110
|
+
args = [expr(arg) for arg in node["args"]]
|
|
111
|
+
kwargs = {key: expr(value) for key, value in node["kwargs"]}
|
|
112
|
+
env[node["target"]] = getattr(anno, node["operation"])(*args, **kwargs)
|
|
113
|
+
elif kind == "constrain":
|
|
114
|
+
valid = expr(node["condition"])
|
|
115
|
+
message = expr(node["message"]) if node["message"] is not None else None
|
|
116
|
+
if not valid:
|
|
117
|
+
raise ValueError(message or "Constraint failed")
|
|
118
|
+
elif kind == "return":
|
|
119
|
+
raise _Returned(expr(node["value"]))
|
|
120
|
+
else:
|
|
121
|
+
raise AssertionError(f"Unknown statement {kind}")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
block(program["body"])
|
|
125
|
+
except _Returned as result:
|
|
126
|
+
return result.value
|
|
127
|
+
raise AssertionError("Program ended without return")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def walk_json(value):
|
|
131
|
+
"""Traverse a decoded tree for structural acceptance checks."""
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
yield value
|
|
134
|
+
for item in value.values():
|
|
135
|
+
yield from walk_json(item)
|
|
136
|
+
elif isinstance(value, list):
|
|
137
|
+
for item in value:
|
|
138
|
+
yield from walk_json(item)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Loopback-only process fixtures for durable-job acceptance, never a deployment."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def server_components(folder):
|
|
9
|
+
from annoscript.server.releases import Releases
|
|
10
|
+
from annoscript.server.store import Store
|
|
11
|
+
from annoscript.test_jobs_server import configurations
|
|
12
|
+
clock = lambda: float(Path(folder, "clock").read_text())
|
|
13
|
+
return Store(Path(folder, "jobs.sqlite"), clock=clock, lease_seconds=0.4), Releases(configurations(), "test")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def serve_api(folder, port):
|
|
17
|
+
import asyncio
|
|
18
|
+
import socket
|
|
19
|
+
import uvicorn
|
|
20
|
+
from annoscript.server.api import create_app
|
|
21
|
+
store, releases = server_components(folder)
|
|
22
|
+
sock = socket.socket()
|
|
23
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
24
|
+
sock.bind(("127.0.0.1", port))
|
|
25
|
+
sock.listen(128)
|
|
26
|
+
server = uvicorn.Server(uvicorn.Config(create_app(store, releases, {"offline-token": "acceptance"}),
|
|
27
|
+
log_level="error", access_log=False))
|
|
28
|
+
async def run():
|
|
29
|
+
task = asyncio.create_task(server.serve(sockets=[sock]))
|
|
30
|
+
while not server.started:
|
|
31
|
+
if task.done():
|
|
32
|
+
await task
|
|
33
|
+
raise RuntimeError("test API did not start")
|
|
34
|
+
await asyncio.sleep(0.01)
|
|
35
|
+
print(json.dumps({"url": f"http://127.0.0.1:{sock.getsockname()[1]}"}), flush=True)
|
|
36
|
+
await task
|
|
37
|
+
asyncio.run(run())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serve_worker(folder):
|
|
41
|
+
import asyncio
|
|
42
|
+
from annoscript._data import thaw
|
|
43
|
+
from annoscript._test_remote_server import cases
|
|
44
|
+
from annoscript._test_synthetic import CannedBackend
|
|
45
|
+
from annoscript.backends import InferenceOutput
|
|
46
|
+
from annoscript.anno import GenerateResponse
|
|
47
|
+
from annoscript.server.worker import Worker
|
|
48
|
+
store, releases = server_components(folder)
|
|
49
|
+
scenarios, _, _, failures = cases()
|
|
50
|
+
class Backend(CannedBackend):
|
|
51
|
+
async def infer(self, context, call):
|
|
52
|
+
state = thaw(context.state)
|
|
53
|
+
if type(state) is str:
|
|
54
|
+
if state == "__hold__":
|
|
55
|
+
Path(folder, "model-started").touch()
|
|
56
|
+
while not Path(folder, "release-model").exists():
|
|
57
|
+
await asyncio.sleep(0.01)
|
|
58
|
+
return InferenceOutput(GenerateResponse(state))
|
|
59
|
+
return await super().infer(context, call)
|
|
60
|
+
def factory(manifest):
|
|
61
|
+
backend = Backend(scenarios, failures, speculative=True)
|
|
62
|
+
return backend, backend
|
|
63
|
+
print(json.dumps({"worker": "ready"}), flush=True)
|
|
64
|
+
asyncio.run(Worker(store, releases, backend_factory=factory, random_source=lambda: 0,
|
|
65
|
+
poll_interval=0.01).run())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fixtures(folder):
|
|
69
|
+
from annoscript._test_remote_server import cases
|
|
70
|
+
_, inputs, expected, _ = cases()
|
|
71
|
+
Path(folder, "fixtures.json").write_text(json.dumps({"inputs": inputs[:8], "expected": expected[:8]}, ensure_ascii=False), encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_client(url, folder, action):
|
|
75
|
+
# Fail fast on accidental import coupling in a real Python 3.10 process.
|
|
76
|
+
import importlib.abc
|
|
77
|
+
import sys
|
|
78
|
+
class Block(importlib.abc.MetaPathFinder):
|
|
79
|
+
def find_spec(self, fullname, *args):
|
|
80
|
+
if fullname.startswith(("annoscript.runtime", "annoscript.backends", "annoscript.plan", "annoscript.server", "fastapi", "uvicorn")):
|
|
81
|
+
raise AssertionError("execution module imported on client: " + fullname)
|
|
82
|
+
sys.meta_path.insert(0, Block())
|
|
83
|
+
import httpx
|
|
84
|
+
from annoscript_examples.request import annotate
|
|
85
|
+
from annoscript.client import AnnoClient
|
|
86
|
+
from annoscript.compiler import compile_program
|
|
87
|
+
from annoscript.jobs import AnnoJobClient
|
|
88
|
+
from annoscript.test_jobs import generate
|
|
89
|
+
if action == "matrix":
|
|
90
|
+
data = json.loads(Path(folder, "fixtures.json").read_text())
|
|
91
|
+
class DropResponses(httpx.BaseTransport):
|
|
92
|
+
def __init__(self):
|
|
93
|
+
self.inner = httpx.HTTPTransport(retries=0)
|
|
94
|
+
self.dropped = set()
|
|
95
|
+
def handle_request(self, request):
|
|
96
|
+
response = self.inner.handle_request(request)
|
|
97
|
+
kind = "accept" if request.url.path == "/v1/jobs" else "page" if request.url.path.endswith("/results") else None
|
|
98
|
+
if kind and kind not in self.dropped:
|
|
99
|
+
self.dropped.add(kind)
|
|
100
|
+
response.read()
|
|
101
|
+
response.close()
|
|
102
|
+
raise httpx.ReadError("deliberately lost committed HTTP response")
|
|
103
|
+
return response
|
|
104
|
+
def close(self):
|
|
105
|
+
self.inner.close()
|
|
106
|
+
for mode in ("strict", "eager", "speculative"):
|
|
107
|
+
for optimize in (False, True):
|
|
108
|
+
for ordered in (False, True):
|
|
109
|
+
with AnnoClient.remote(url, "offline-token", mode=mode, optimize=optimize,
|
|
110
|
+
transport=DropResponses()) as client:
|
|
111
|
+
client._remote.session.client.sleep = lambda _: None
|
|
112
|
+
results = list(client.submit(annotate, data["inputs"], ordered=ordered))
|
|
113
|
+
assert len(results) == len(data["expected"])
|
|
114
|
+
if ordered:
|
|
115
|
+
assert [r.index for r in results] == list(range(len(results)))
|
|
116
|
+
by_index = {r.index: r for r in results}
|
|
117
|
+
assert len(by_index) == len(results)
|
|
118
|
+
for index, expected in enumerate(data["expected"]):
|
|
119
|
+
result = by_index[index]
|
|
120
|
+
assert result.status == expected["status"], (index, result)
|
|
121
|
+
if result.status == "success":
|
|
122
|
+
assert result.value == expected["value"], (index, result)
|
|
123
|
+
else:
|
|
124
|
+
assert result.error.code == expected["code"], (index, result)
|
|
125
|
+
assert by_index[2].value["tags"] == ["sample"]
|
|
126
|
+
print(json.dumps({"matrix": "12 combinations passed"}), flush=True)
|
|
127
|
+
elif action == "hold-session":
|
|
128
|
+
client = AnnoClient.remote(url, "offline-token")
|
|
129
|
+
stream = client.submit(generate, ["__hold__"])
|
|
130
|
+
print(json.dumps({"job_id": stream.job["job_id"]}), flush=True)
|
|
131
|
+
sys.stdin.readline() # Parent kills the process; no destructor can run.
|
|
132
|
+
client.close()
|
|
133
|
+
elif action == "submit-durable":
|
|
134
|
+
with AnnoJobClient(url, "offline-token") as client:
|
|
135
|
+
job = client.submit_ir(compile_program(generate), [{"index": 42, "state": "__hold__"}], idempotency_key="durable")
|
|
136
|
+
print(json.dumps(job), flush=True)
|
|
137
|
+
elif action == "read-durable":
|
|
138
|
+
with AnnoJobClient(url, "offline-token") as client:
|
|
139
|
+
job = client.lookup("durable")
|
|
140
|
+
page = client.fetch_results(job)
|
|
141
|
+
assert page["done"] and page["results"][0]["index"] == 42
|
|
142
|
+
assert page["results"][0]["value"] == "__hold__"
|
|
143
|
+
assert page["results"][0]["attempts"] == 2
|
|
144
|
+
print(json.dumps({"recovery": "passed"}), flush=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main():
|
|
148
|
+
parser = argparse.ArgumentParser()
|
|
149
|
+
parser.add_argument("kind", choices=("api", "worker", "fixtures", "client"))
|
|
150
|
+
parser.add_argument("folder")
|
|
151
|
+
parser.add_argument("--url")
|
|
152
|
+
parser.add_argument("--port", type=int, default=0)
|
|
153
|
+
parser.add_argument("--action", default="matrix")
|
|
154
|
+
args = parser.parse_args()
|
|
155
|
+
if args.kind == "api":
|
|
156
|
+
serve_api(args.folder, args.port)
|
|
157
|
+
elif args.kind == "worker":
|
|
158
|
+
serve_worker(args.folder)
|
|
159
|
+
elif args.kind == "fixtures":
|
|
160
|
+
fixtures(args.folder)
|
|
161
|
+
else:
|
|
162
|
+
run_client(args.url, args.folder, args.action)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|