annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
annoscript/formatter.py
ADDED
|
@@ -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())
|