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,171 @@
1
+ """Readable, non-executable notation for any version-1 AnnoScript IR program.
2
+
3
+ format_ir(program, show_source=False) preserves every statement, temporary name,
4
+ literal and control-flow block. It neither optimizes nor runs the program and
5
+ does not reconstruct the original helper functions. format_value/format_string
6
+ in the output denote the corresponding IR expressions, not Python functions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import keyword
12
+
13
+ from annoscript import ir
14
+
15
+
16
+ _COMPARE = {
17
+ "eq": "==", "ne": "!=", "lt": "<", "le": "<=", "gt": ">", "ge": ">=",
18
+ "in": "in", "not_in": "not in", "is": "is", "is_not": "is not",
19
+ }
20
+
21
+
22
+ def _identifier(name: str) -> bool:
23
+ return name.isidentifier() and not keyword.iskeyword(name)
24
+
25
+
26
+ def _name(name: str) -> str:
27
+ return name if _identifier(name) else f"var[{name!r}]"
28
+
29
+
30
+ def _arguments(args, kwargs) -> str:
31
+ parts = [_expression(arg) for arg in args]
32
+ for name, value in kwargs:
33
+ if _identifier(name):
34
+ parts.append(f"{name}={_expression(value)}")
35
+ else:
36
+ parts.append(f"**{{{name!r}: {_expression(value)}}}")
37
+ return ", ".join(parts)
38
+
39
+
40
+ def _base(node: ir.Expr) -> str:
41
+ text = _expression(node)
42
+ if isinstance(node, (ir.Var, ir.State, ir.GetItem, ir.Field, ir.Intrinsic)):
43
+ return text
44
+ return f"({text})"
45
+
46
+
47
+ def _expression(node: ir.Expr) -> str:
48
+ match node:
49
+ case ir.Literal(value):
50
+ return repr(value)
51
+ case ir.Var(name):
52
+ return _name(name)
53
+ case ir.State():
54
+ return "anno.state"
55
+ case ir.GetItem(value, index):
56
+ return f"{_base(value)}[{_expression(index)}]"
57
+ case ir.Field(value, name):
58
+ return f"{_base(value)}.{name}"
59
+ case ir.ListExpr(items):
60
+ return "[" + ", ".join(map(_expression, items)) + "]"
61
+ case ir.TupleExpr(items):
62
+ return "(" + ", ".join(map(_expression, items)) + ("," if len(items) == 1 else "") + ")"
63
+ case ir.DictExpr(entries):
64
+ return "{" + ", ".join(f"{_expression(k)}: {_expression(v)}" for k, v in entries) + "}"
65
+ case ir.Unary(op, value):
66
+ prefix = {"not": "not ", "negative": "-", "positive": "+"}[op]
67
+ return f"({prefix}{_expression(value)})"
68
+ case ir.Binary("add", left, right):
69
+ return f"({_expression(left)} + {_expression(right)})"
70
+ case ir.Compare(left, ops, comparators):
71
+ parts = [_expression(left)]
72
+ for op, right in zip(ops, comparators):
73
+ parts.extend((_COMPARE[op], _expression(right)))
74
+ return "(" + " ".join(parts) + ")"
75
+ case ir.BoolOp(op, values):
76
+ return "(" + f" {op} ".join(map(_expression, values)) + ")"
77
+ case ir.Intrinsic(name, args, kwargs):
78
+ return f"{name}({_arguments(args, kwargs)})"
79
+ case ir.FormatValue(value, conversion, spec):
80
+ parts = [_expression(value)]
81
+ if conversion != "none":
82
+ parts.append(f"conversion={conversion!r}")
83
+ if spec is not None:
84
+ parts.append(f"spec={_expression(spec)}")
85
+ return "format_value(" + ", ".join(parts) + ")"
86
+ case ir.FormatString(parts):
87
+ return "format_string(" + ", ".join(map(_expression, parts)) + ")"
88
+ raise ValueError(f"Unsupported IR expression: {type(node).__name__}")
89
+
90
+
91
+ def _target(node: ir.Target) -> str:
92
+ match node:
93
+ case ir.NameTarget(name):
94
+ return _name(name)
95
+ case ir.TupleTarget(items):
96
+ return "(" + ", ".join(map(_target, items)) + ("," if len(items) == 1 else "") + ")"
97
+ raise ValueError(f"Unsupported IR target: {type(node).__name__}")
98
+
99
+
100
+ def _location(span: ir.SourceSpan) -> str:
101
+ return f"{span.module}:{span.line}:{span.column} ({span.function})"
102
+
103
+
104
+ def _comment(text: str) -> str:
105
+ # Keep embedded newlines/control characters in metadata on one comment line.
106
+ return "# " + repr(text)[1:-1]
107
+
108
+
109
+ class _Formatter:
110
+ def __init__(self, show_source):
111
+ self.show_source = show_source
112
+ self.lines = []
113
+
114
+ def line(self, depth, text):
115
+ self.lines.append(" " * depth + text)
116
+
117
+ def block(self, body, depth):
118
+ if not body:
119
+ self.line(depth, "pass")
120
+ for node in body:
121
+ if self.show_source:
122
+ text = f"[{node.id}] {_location(node.source.span)}"
123
+ if node.source.calls:
124
+ text += "; inlined from " + " -> ".join(map(_location, node.source.calls))
125
+ self.line(depth, _comment(text))
126
+ match node:
127
+ case ir.Assign(target, value):
128
+ self.line(depth, f"{_name(target)} = {_expression(value)}")
129
+ case ir.Unpack(target, value):
130
+ self.line(depth, f"{_target(target)} = {_expression(value)}")
131
+ case ir.SetItem(value, index, item):
132
+ self.line(depth, f"{_base(value)}[{_expression(index)}] = {_expression(item)}")
133
+ case ir.If(condition, then_body, else_body):
134
+ self.line(depth, f"if {_expression(condition)}:")
135
+ self.block(then_body, depth + 1)
136
+ if else_body:
137
+ self.line(depth, "else:")
138
+ self.block(else_body, depth + 1)
139
+ case ir.For(target, iterable, body):
140
+ self.line(depth, f"for {_target(target)} in {_expression(iterable)}:")
141
+ self.block(body, depth + 1)
142
+ case ir.Infer(target, operation, args, kwargs):
143
+ self.line(depth, f"{_name(target)} = anno.{operation}({_arguments(args, kwargs)})")
144
+ case ir.Constrain(condition, message):
145
+ text = f"constrain({_expression(condition)}"
146
+ if message is not None:
147
+ text += f", message={_expression(message)}"
148
+ self.line(depth, text + ")")
149
+ case ir.Return(value):
150
+ self.line(depth, f"return {_expression(value)}")
151
+ case _:
152
+ raise ValueError(f"Unsupported IR statement: {type(node).__name__}")
153
+
154
+
155
+ def format_ir(program: ir.Program, *, show_source: bool = False) -> str:
156
+ """Render a Program as deterministic display text, ending with a newline.
157
+
158
+ Load external data through Program.from_json/from_dict first. The output is
159
+ an inspection format, not executable Python or a serialization format.
160
+ """
161
+ if not isinstance(program, ir.Program):
162
+ raise TypeError("format_ir expects an ir.Program")
163
+ formatter = _Formatter(show_source)
164
+ formatter.line(0, f"# AnnoScript IR v{program.version} (display notation)")
165
+ if show_source:
166
+ formatter.line(0, _comment(_location(program.source)))
167
+ if program.config.system_prompt is not None:
168
+ formatter.line(0, f"system_prompt {program.config.system_prompt!r}")
169
+ formatter.line(0, f"program {program.entry!r}:")
170
+ formatter.block(program.body, 1)
171
+ return "\n".join(formatter.lines) + "\n"
@@ -0,0 +1,176 @@
1
+ """Required CPU-only XGrammar acceptance: python3.12 -m annoscript.grammar_tests.
2
+
3
+ Separate from unittest discovery: XGrammar is a test dependency, never a client
4
+ or execution dependency. No model, tokenizer files or GPU are used or downloaded.
5
+ Missing/wrong dependencies fail this entry point instead of skipping acceptance.
6
+ """
7
+
8
+ from importlib.metadata import version
9
+ import json
10
+ import sys
11
+ import unittest
12
+ from types import SimpleNamespace
13
+
14
+ from annoscript.backends._grammar import ITEMS_GRAMMAR, BATCH_GRAMMAR, batch_grammar
15
+
16
+
17
+ class CompactGrammarTests(unittest.TestCase):
18
+ grammar = ITEMS_GRAMMAR
19
+ @classmethod
20
+ def setUpClass(cls):
21
+ if sys.version_info[:2] != (3, 12):
22
+ raise RuntimeError("grammar acceptance requires Python 3.12")
23
+ if version("xgrammar") != "0.2.0":
24
+ raise RuntimeError("install requirements-test-grammar.txt (xgrammar==0.2.0)")
25
+ import xgrammar as xgr
26
+ cls.xgr = xgr
27
+ grammar = xgr.Grammar.from_ebnf(cls.grammar)
28
+ # Empty vocabulary: compile/match strings without any model/tokenizer.
29
+ cls.compiled = xgr.GrammarCompiler(xgr.TokenizerInfo([]), cache_enabled=False).compile_grammar(grammar)
30
+
31
+ def accepts(self, text):
32
+ matcher = self.xgr.GrammarMatcher(self.compiled, terminate_without_stop_token=True)
33
+ return matcher.accept_string(text) and matcher.is_terminated()
34
+
35
+ def test_json_strings_unicode_and_escapes(self):
36
+ for values in ([], [""], ["中文", "😀", " A B "], ['"\\/\b\f\n\r\t'],
37
+ ["\x00\x1f"], ["e\u0301", "\u2028\u2029"], ["条目"]):
38
+ for ascii_only in (True, False):
39
+ payload = json.dumps({"items": values}, ensure_ascii=ascii_only, separators=(",", ":"))
40
+ with self.subTest(payload=payload):
41
+ self.assertTrue(self.accepts(payload))
42
+ self.assertEqual(json.loads(payload)["items"], values)
43
+ self.assertTrue(self.accepts(r'{"items":["\/\u4E2D"]}'))
44
+
45
+ def test_structure_has_no_whitespace_outside_strings(self):
46
+ for payload in (' {"items":[]}', '{"items":[]}\n', '\n{"items":[]}',
47
+ '{ "items":[]}', '{"items" :[]}', '{"items": []}',
48
+ '{"items":[ ]}', '{"items":["a", "b"]}', '{"items":["a"] }'):
49
+ with self.subTest(payload=payload):
50
+ self.assertFalse(self.accepts(payload))
51
+
52
+ def test_extra_fields_types_and_invalid_strings_are_rejected(self):
53
+ for payload in ('{}', '[]', 'null', '{"items":null}', '{"items":"x"}',
54
+ '{"items":[1]}', '{"items":[true]}', '{"items":[null]}',
55
+ '{"items":[{}]}', '{"items":[[]]}', '{"items":[],"extra":0}',
56
+ '{"items":[],"items":[]}', '{"other":[]}', '{"items":["a",]}',
57
+ '{"items":["a\nb"]}', r'{"items":["\x20"]}',
58
+ r'{"items":["\u12Q4"]}', '{"items":["\x00"]}'):
59
+ with self.subTest(payload=payload):
60
+ self.assertFalse(self.accepts(payload))
61
+
62
+ def test_incomplete_output_is_not_a_complete_match(self):
63
+ payload = '{"items":["中文","条目"]}'
64
+ for end in range(len(payload)):
65
+ with self.subTest(end=end):
66
+ self.assertFalse(self.accepts(payload[:end]))
67
+ self.assertTrue(self.accepts(payload))
68
+
69
+ def test_business_bounds_are_left_to_runtime(self):
70
+ # Grammar remains reusable for all programs, not just application field bounds.
71
+ self.assertTrue(self.accepts(json.dumps({"items": ["x" * 301] * 17}, separators=(",", ":"))))
72
+
73
+
74
+ class BatchGrammarTests(unittest.TestCase):
75
+ grammar = BATCH_GRAMMAR
76
+ setUpClass = classmethod(CompactGrammarTests.setUpClass.__func__)
77
+ accepts = CompactGrammarTests.accepts
78
+
79
+ def test_mixed_answers_unicode_and_escapes(self):
80
+ for ascii_only in (False, True):
81
+ payload = json.dumps({"answers": {
82
+ "q1": ' 中文😀 "引号" \\路径\n\x00',
83
+ "q2": ["", "条目"],
84
+ "q3": [],
85
+ }}, ensure_ascii=ascii_only, separators=(",", ":"))
86
+ self.assertTrue(self.accepts(payload))
87
+ for end in range(len(payload)):
88
+ self.assertFalse(self.accepts(payload[:end]))
89
+
90
+ def test_bad_types_extra_fields_and_outer_whitespace(self):
91
+ good = '{"answers":{"q1":"x"}}'
92
+ for payload in (" " + good, good + "\n", good.replace(':"x"', ': "x"'),
93
+ good.replace('"x"', 'null'), good.replace('"x"', 'true'),
94
+ good.replace('"x"', '[null]'), good.replace('"x"', '{"text":"x"}'),
95
+ good[:-1] + ',"extra":0}', '{}', '[]'):
96
+ with self.subTest(payload=payload):
97
+ self.assertFalse(self.accepts(payload))
98
+
99
+
100
+ class BoundBatchGrammarTests(unittest.TestCase):
101
+ calls = [SimpleNamespace(id='extract"\n😀', operation="extract", arguments={}),
102
+ SimpleNamespace(id="list", operation="generate_list", arguments={}),
103
+ SimpleNamespace(id="text", operation="generate", arguments={})]
104
+ grammar = batch_grammar(calls)
105
+ setUpClass = classmethod(CompactGrammarTests.setUpClass.__func__)
106
+ accepts = CompactGrammarTests.accepts
107
+
108
+ def answer(self):
109
+ return {"answers": {
110
+ "q1": ["广州"], "q2": ["😀", "", '"\\\n'], "q3": "标题"}}
111
+
112
+ def encode(self, value):
113
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
114
+
115
+ def test_exact_mapping_unicode_escaping_and_completion(self):
116
+ payload = self.encode(self.answer())
117
+ self.assertTrue(self.accepts(payload))
118
+ for end in range(len(payload)):
119
+ self.assertFalse(self.accepts(payload[:end]))
120
+
121
+ def test_wrong_type_for_extract_id_is_impossible(self):
122
+ for value in ("广州", {"type": "extract", "items": ["广州"]}):
123
+ answer = self.answer()
124
+ answer["answers"]["q1"] = value
125
+ self.assertFalse(self.accepts(self.encode(answer)))
126
+
127
+ def test_unknown_missing_and_duplicate_ids_are_impossible(self):
128
+ answer = self.answer()
129
+ del answer["answers"]["q2"]
130
+ self.assertFalse(self.accepts(self.encode(answer)))
131
+ answer = self.answer()
132
+ answer["answers"]["unknown"] = answer["answers"]["q2"]
133
+ self.assertFalse(self.accepts(self.encode(answer)))
134
+ good = self.encode(self.answer())
135
+ self.assertFalse(self.accepts(good[:-2] + ',"q3":"x"}}'))
136
+ self.assertFalse(self.accepts('{"answers":{}}'))
137
+
138
+
139
+ class NamedBatchGrammarTests(unittest.TestCase):
140
+ calls = [SimpleNamespace(id="a", operation="extract", arguments={"field_name": ' 地点"\\\n😀 '}),
141
+ SimpleNamespace(id="b", operation="generate_list", arguments={"field_name": "tags"}),
142
+ SimpleNamespace(id="c", operation="generate", arguments={})]
143
+ grammar = batch_grammar(calls)
144
+ setUpClass = classmethod(CompactGrammarTests.setUpClass.__func__)
145
+ accepts = CompactGrammarTests.accepts
146
+
147
+ def test_named_and_automatic_keys_and_escaping(self):
148
+ value = {"answers": {' 地点"\\\n😀 ': ["广州"], "tags": ["条目"], "q3": "标题"}}
149
+ # Grammar key literals use JSON's ASCII escapes; decoded names are exact.
150
+ encoded = json.dumps(value, ensure_ascii=True, separators=(",", ":"))
151
+ self.assertTrue(self.accepts(encoded))
152
+ self.assertFalse(self.accepts(encoded.replace('tags', 'q2')))
153
+ value["answers"]["q3"] = []
154
+ self.assertFalse(self.accepts(json.dumps(value, ensure_ascii=True, separators=(",", ":"))))
155
+
156
+
157
+ class NullableBatchGrammarTests(unittest.TestCase):
158
+ grammar = batch_grammar([
159
+ SimpleNamespace(id="a", operation="generate", arguments={"nullable": True}),
160
+ SimpleNamespace(id="b", operation="generate", arguments={}),
161
+ SimpleNamespace(id="c", operation="generate_list", arguments={}),
162
+ ])
163
+ setUpClass = classmethod(CompactGrammarTests.setUpClass.__func__)
164
+ accepts = CompactGrammarTests.accepts
165
+
166
+ def test_only_nullable_text_accepts_null(self):
167
+ for value in (None, "null", "中文", ""):
168
+ good = json.dumps({"answers": {"q1": value, "q2": "标题", "q3": []}}, ensure_ascii=False, separators=(",", ":"))
169
+ self.assertTrue(self.accepts(good))
170
+ for value in ({"q1": [], "q2": "标题", "q3": []},
171
+ {"q1": None, "q2": None, "q3": []}, {"q1": None, "q2": "标题", "q3": None}):
172
+ self.assertFalse(self.accepts(json.dumps({"answers": value}, separators=(",", ":"))))
173
+
174
+
175
+ if __name__ == "__main__":
176
+ unittest.main()
annoscript/inspect.py ADDED
@@ -0,0 +1,77 @@
1
+ """Inspect AnnoScript IR or an Execution Plan without importing or running its source.
2
+
3
+ Usage from the repository root (or with AnnoScript on PYTHONPATH):
4
+
5
+ python3 -m annoscript.inspect path/to/program.json
6
+ python3 -m annoscript.inspect path/to/program.json --show-source
7
+ cat path/to/program.json | python3 -m annoscript.inspect -
8
+ python3 -m annoscript.inspect request.execution_plan.json --mode strict
9
+ python3 -m annoscript.inspect request.execution_plan.json --format html -o plan.html
10
+
11
+ For Python callers, use Program.from_json + annoscript.formatter.format_ir, or
12
+ ExecutionPlan.from_json + annoscript.plan_formatter.format_plan / format_plan_html.
13
+ Output goes to stdout unless --output is provided. Inspection does not lower
14
+ or optimize IR. HTML is self-contained and can be opened offline in a browser.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ from pathlib import Path
22
+ import sys
23
+
24
+ from annoscript.formatter import format_ir
25
+ from annoscript.ir import IRDecodeError, Program
26
+ from annoscript.plan import ExecutionPlan
27
+ from annoscript.plan_formatter import format_plan, format_plan_html, format_plan_mermaid
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ parser = argparse.ArgumentParser(
32
+ prog="python3 -m annoscript.inspect",
33
+ description="Inspect AnnoScript IR or visualize Execution Plan Infer dependencies without execution.",
34
+ )
35
+ parser.add_argument("path", metavar="PATH", help="AnnoScript IR / Execution Plan JSON, or - for stdin")
36
+ parser.add_argument("--show-source", action="store_true", help="show statement IDs, source locations and inline call chains")
37
+ parser.add_argument("--format", choices=("text", "html", "mermaid"), default="text", help="output format (HTML/Mermaid require an Execution Plan)")
38
+ parser.add_argument("--mode", choices=("eager", "strict", "speculative"), default="eager", help="Execution Plan failure mode; HTML includes all three modes")
39
+ parser.add_argument("-o", "--output", metavar="PATH", help="write UTF-8 output to this file instead of stdout")
40
+ args = parser.parse_args(argv)
41
+ try:
42
+ text = sys.stdin.read() if args.path == "-" else Path(args.path).read_text(encoding="utf-8")
43
+ # Only sniff the format here. Each strict decoder parses the original
44
+ # text again, so duplicate JSON keys and non-finite values stay errors.
45
+ header = json.loads(text)
46
+ if isinstance(header, dict) and header.get("format") == "lev-execution-plan":
47
+ plan = ExecutionPlan.from_json(text)
48
+ formatter = {"text": format_plan, "html": format_plan_html, "mermaid": format_plan_mermaid}[args.format]
49
+ output = formatter(plan, mode=args.mode, show_source=args.show_source)
50
+ else:
51
+ program = Program.from_json(text)
52
+ if args.format != "text" or args.mode != "eager":
53
+ raise IRDecodeError("--format html/mermaid and --mode strict/speculative require an Execution Plan; lower the IR explicitly first")
54
+ output = format_ir(program, show_source=args.show_source)
55
+ if args.output and args.output != "-":
56
+ if args.path != "-" and Path(args.output).resolve() == Path(args.path).resolve():
57
+ raise ValueError("output path must differ from the input JSON file")
58
+ Path(args.output).write_text(output, encoding="utf-8")
59
+ return 0
60
+ except (OSError, UnicodeError, ValueError, RecursionError) as exc:
61
+ print(f"annoscript.inspect: {exc}", file=sys.stderr)
62
+ return 2
63
+ try:
64
+ sys.stdout.write(output)
65
+ sys.stdout.flush()
66
+ except BrokenPipeError:
67
+ # A pager may stop reading early. Closing also prevents a second failed
68
+ # flush during interpreter shutdown.
69
+ try:
70
+ sys.stdout.close()
71
+ except BrokenPipeError:
72
+ pass
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())