annoscript 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
@@ -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()