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,180 @@
1
+ """P0 wire, preflight, immutable release, and local error regressions; no model calls."""
2
+ import copy
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ from unittest.mock import patch, Mock
8
+ import httpx
9
+ from annoscript.backends import InferenceContext, InferenceError, BackendFatalError
10
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
11
+ from annoscript.backends.base import normalize_call
12
+ from annoscript.backends._generation_policy import SAMPLING, PinnedTokenCounter
13
+ from annoscript.server.releases import manifest
14
+
15
+
16
+ def policy(window=8192):
17
+ return {'sampling':dict(SAMPLING),'default_max_tokens':1024,'context_window':window,
18
+ 'bounded_outputs':True,'tokenizer':{'revision':'fixture-revision','files':
19
+ {k:'0'*64 for k in ('tokenizer.json','tokenizer_config.json','chat_template.jinja')}}}
20
+
21
+
22
+ def call(ctx, op='generate', id='x', **kwargs):
23
+ defaults = {'max_length':512} if op=='generate' else {'max_items':8,'max_length_per_item':58}
24
+ return normalize_call(id,op,('任务',),{**defaults, **kwargs},ctx)
25
+
26
+
27
+ class GenerationPolicyTests(unittest.IsolatedAsyncioTestCase):
28
+ def backend(self, handler, *, counter=lambda payload:10, window=8192, diagnostics=None):
29
+ return LLMAPIBackend('https://test.invalid/v1','fixture','qwen',profile='qwen_vllm',
30
+ extract_mode='substring_chars',request_policy=policy(window),_token_counter=counter,
31
+ transport=httpx.MockTransport(handler),diagnostic_observer=diagnostics)
32
+
33
+ async def test_single_and_repair_sampling_budget_and_bounds(self):
34
+ ctx=InferenceContext('甲乙','共享策略')
35
+ for op, text in [('generate','甲'),('extract','{"items":["甲"]}'),('generate_list','{"items":["甲"]}')]:
36
+ for budget in ((None,) if op=="extract" else (None,160,600)):
37
+ for repair in (None,'bad format'):
38
+ sent=[]
39
+ def handle(req):
40
+ sent.append(json.loads(req.content))
41
+ return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':text}}]})
42
+ b=self.backend(handle)
43
+ kwargs={} if op=='extract' else {'max_num_tokens':budget}
44
+ kwargs.update({'max_length':2} if op=='generate' else {'max_items':2,'max_length_per_item':2})
45
+ try:
46
+ await b.infer(ctx,call(ctx,op,**kwargs),repair_feedback=repair,max_tokens_override=1024 if repair else None)
47
+ finally: await b.aclose()
48
+ self.assertEqual(len(sent),1);p=sent[0]
49
+ self.assertEqual(p['max_tokens'],budget or (4 if op=='generate' else 56))
50
+ self.assertEqual({k:p[k] for k in SAMPLING},SAMPLING)
51
+ self.assertEqual(p['chat_template_kwargs'],{'enable_thinking':False})
52
+ self.assertIn('grammar',p['structured_outputs'])
53
+
54
+ async def test_context_exact_boundary_and_one_over_zero_http(self):
55
+ ctx=InferenceContext('甲');sent=[];events=[]
56
+ def handle(req):
57
+ sent.append(req);return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':'甲'}}]})
58
+ for input_tokens in (7168,7169):
59
+ b=self.backend(handle,counter=lambda p:input_tokens,diagnostics=events.append)
60
+ try:
61
+ if input_tokens==7168: await b.infer(ctx,call(ctx))
62
+ else:
63
+ with self.assertRaises(InferenceError) as cm: await b.infer(ctx,call(ctx),repair_feedback='bad')
64
+ self.assertEqual(cm.exception.code,'context_length_exceeded')
65
+ self.assertEqual(cm.exception.metadata['dispatch_status'],'not_sent')
66
+ finally: await b.aclose()
67
+ self.assertEqual(len(sent),1)
68
+ local=[e for e in events if e['event']=='local_rejection']
69
+ self.assertEqual(len(local),1);self.assertNotIn('request_id',local[0]);self.assertTrue(local[0]['repair'])
70
+
71
+ async def test_batch_prefix_default_and_infer_preflight(self):
72
+ ctx=InferenceContext('甲乙');sent=[]
73
+ b=self.backend(lambda req:sent.append(req),counter=lambda p:7900)
74
+ calls=[call(ctx,'extract',str(i),max_items=2,max_length_per_item=2) for i in range(4)]
75
+ try:
76
+ # Two calls fit: 7900 + 240. Three do not: 7900 + 328.
77
+ plan=b.plan_batch(ctx,calls,max_calls=8,max_input_bytes=1000000,max_output_tokens=4096,default_tokens=999)
78
+ self.assertEqual(len(plan.calls),2);self.assertEqual(plan.max_tokens,240)
79
+ with self.assertRaises(InferenceError): await b.infer_batch(ctx,calls,max_tokens=4096)
80
+ self.assertFalse(sent)
81
+ p=b._batch_payload(ctx,plan.calls,plan.max_tokens)
82
+ self.assertEqual({k:p[k] for k in SAMPLING},SAMPLING)
83
+ finally: await b.aclose()
84
+
85
+ async def test_unsatisfiable_local_and_planner_fallback(self):
86
+ ctx=InferenceContext('');sent=[];events=[]
87
+ b=self.backend(lambda req:sent.append(req),diagnostics=events.append)
88
+ impossible=call(ctx,'extract',min_items=1)
89
+ try:
90
+ self.assertIsNone(b.plan_batch(ctx,[impossible]*2,max_calls=8,max_input_bytes=100000,max_output_tokens=4096,default_tokens=1024))
91
+ with self.assertRaises(InferenceError) as cm: await b.infer(ctx,impossible)
92
+ self.assertEqual(cm.exception.code,'unsatisfiable_constraints');self.assertFalse(sent)
93
+ self.assertEqual(events[0]['dispatch_status'],'not_sent')
94
+ finally: await b.aclose()
95
+
96
+ async def test_provider_ignores_bounds_still_fails(self):
97
+ ctx=InferenceContext('甲乙丙')
98
+ for op, value, kwargs in [('generate','',{'min_length':1,'max_length':2}),
99
+ ('generate_list','{"items":["甲","乙"]}',{'max_items':1}),
100
+ ('extract','{"items":["不在原文"]}',{'max_length_per_item':2})]:
101
+ b=self.backend(lambda req:httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':value}}]}))
102
+ try:
103
+ with self.assertRaises(InferenceError): await b.infer(ctx,call(ctx,op,**kwargs))
104
+ finally: await b.aclose()
105
+
106
+ async def test_diagnostic_failure_never_sends_or_retries(self):
107
+ ctx=InferenceContext('甲');sent=[]
108
+ def broken(event):raise OSError('fixture')
109
+ b=self.backend(lambda req:sent.append(req),counter=lambda p:8192,diagnostics=broken)
110
+ try:
111
+ with self.assertRaises(BackendFatalError):await b.infer(ctx,call(ctx))
112
+ self.assertFalse(sent)
113
+ finally:await b.aclose()
114
+
115
+ def test_counter_requires_ids_not_transformers_default_mapping(self):
116
+ tok=Mock();tok.apply_chat_template.return_value=[1,2,3]
117
+ with patch.dict(os.environ,ANNO_GENERATE_TOKENIZER_DIR='fixture'), patch('annoscript.backends._generation_policy._load_tokenizer',return_value=tok):
118
+ counter=PinnedTokenCounter(policy()['tokenizer'])
119
+ self.assertEqual(counter({'messages':[]}),3)
120
+ self.assertIs(tok.apply_chat_template.call_args.kwargs['return_dict'],False)
121
+ tok.apply_chat_template.return_value={'input_ids':[1,2,3], 'attention_mask':[1,1,1]}
122
+ with self.assertRaises(BackendFatalError):counter({'messages':[]})
123
+
124
+ def test_config_digest_validation_and_release_binding(self):
125
+ cfg={'decision':{'base_url':'https://d.invalid','model':'d','revision':'d'},
126
+ 'generation':{'base_url':'https://q.invalid','model':'q','revision':'fixture-revision','profile':'qwen_vllm',
127
+ 'structured_output_revision':'fixture-decoder','request_policy':policy()}}
128
+ self.assertEqual(manifest(cfg)['generation']['request_policy'],policy())
129
+ for mutate in (lambda x:x['generation'].update(revision='other'),
130
+ lambda x:x['generation'].update(assumed_avg_num_tokens_per_char=0),
131
+ lambda x:x['generation']['request_policy']['sampling'].update(temperature=1)):
132
+ altered=copy.deepcopy(cfg);mutate(altered)
133
+ with self.assertRaises(ValueError):manifest(altered)
134
+ with tempfile.TemporaryDirectory() as root, patch.dict(os.environ,ANNO_GENERATE_TOKENIZER_DIR=root):
135
+ with self.assertRaises(BackendFatalError):PinnedTokenCounter(policy()['tokenizer'])
136
+
137
+
138
+ # Exercise the same pause, accounting and cancellation integration fixtures through P0.
139
+ from annoscript.test_qwen_pilot_regression import ProtocolTests
140
+ from annoscript.test_diagnostics import WorkerTests as DiagnosticWorkerTests
141
+ from annoscript.backends import DecisionAPIBackend
142
+ from annoscript.server.worker import Worker
143
+ import asyncio
144
+
145
+ class P0ProtocolWorkerTests(ProtocolTests):
146
+ def run_http(self, handler, *, observer=None):
147
+ def factory(_):
148
+ return (DecisionAPIBackend('https://offline.invalid','fixture','model',transport=httpx.MockTransport(handler)),
149
+ LLMAPIBackend('https://offline.invalid','fixture','model',profile='qwen_vllm',extract_mode='substring_chars',request_policy=policy(),_token_counter=lambda p:10,transport=httpx.MockTransport(handler)))
150
+ return Worker(self.store,self.releases,backend_factory=factory,concurrency=4,request_observer=observer)
151
+
152
+ class P0DiagnosticWorkerTests(DiagnosticWorkerTests):
153
+ def worker(self, handler, **kw):
154
+ def factory(_):
155
+ return (DecisionAPIBackend('https://offline.invalid','fixture','model',transport=httpx.MockTransport(handler)),
156
+ LLMAPIBackend('https://offline.invalid','fixture','model',profile='qwen_vllm',extract_mode='substring_chars',request_policy=policy(),_token_counter=lambda p:10,transport=httpx.MockTransport(handler)))
157
+ return Worker(self.store,self.releases,backend_factory=factory,**kw)
158
+
159
+ def test_local_context_failure_continues_next_batch_without_retry(self):
160
+ from annoscript.test_jobs_server import payload
161
+ sent=[];events=[]
162
+ def handle(req):
163
+ sent.append(req);return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':'yes'}}]})
164
+ def count(body):return 8192 if body['messages'][-1]['content']=='too long' else 10
165
+ def factory(_):
166
+ return (DecisionAPIBackend('https://offline.invalid','fixture','model',transport=httpx.MockTransport(handle)),
167
+ LLMAPIBackend('https://offline.invalid','fixture','model',profile='qwen_vllm',request_policy=policy(),_token_counter=count,transport=httpx.MockTransport(handle)))
168
+ worker=Worker(self.store,self.releases,backend_factory=factory,diagnostic_observer=events.append)
169
+ for batch in range(2):
170
+ states=tuple('too long' if batch==0 and i==0 else str(i) for i in range(20))
171
+ job=self.store.submit('alice','p0-local-'+str(batch),payload(states),self.releases)
172
+ for _ in range(20):asyncio.run(worker.execute(self.store.claim('w',self.releases)))
173
+ status=self.store.status('alice',job['job_id'])
174
+ self.assertEqual(status['state'],'completed');self.assertEqual(status['counts']['error'],1 if batch==0 else 0)
175
+ self.assertIsNone(self.store.claim('w',self.releases))
176
+ self.assertEqual(len(sent),39)
177
+ local=[e for e in events if e['event']=='local_rejection'];self.assertEqual(len(local),1)
178
+ self.assertIn('job_id',local[0]);self.assertIn('index',local[0])
179
+
180
+ if __name__=="__main__":unittest.main()
annoscript/test_ir.py ADDED
@@ -0,0 +1,148 @@
1
+ from dataclasses import FrozenInstanceError
2
+ from copy import deepcopy
3
+ import json
4
+ from pathlib import Path
5
+ import unittest
6
+
7
+ from annoscript import ir
8
+
9
+
10
+ class IRTests(unittest.TestCase):
11
+ def program(self, expr, prompt=None):
12
+ span = ir.SourceSpan("example", "annotate", 1, 1, 2, 1)
13
+ return ir.Program("annotate", ir.ProgramConfig(prompt), (
14
+ ir.Return(expr, id="return", source=ir.Source(span)),
15
+ ), span)
16
+
17
+ def test_unicode_null_and_detached_export(self):
18
+ program = self.program(ir.DictExpr(((ir.Literal("标题"), ir.Literal(None)),)), "规则")
19
+ text = program.to_json()
20
+ self.assertIn("标题", text)
21
+ self.assertIn("null", text)
22
+ self.assertEqual(json.loads(text), program.to_dict())
23
+ changed = program.to_dict()
24
+ changed["body"].clear()
25
+ changed["config"]["system_prompt"] = "changed"
26
+ self.assertEqual(len(program.body), 1)
27
+ self.assertEqual(program.config.system_prompt, "规则")
28
+
29
+ def test_tuple_and_list_have_distinct_tags(self):
30
+ expression = ir.TupleExpr((ir.ListExpr(()), ir.TupleExpr(())))
31
+ encoded = self.program(expression).to_dict()["body"][0]["value"]
32
+ self.assertEqual(encoded["kind"], "tuple")
33
+ self.assertEqual([n["kind"] for n in encoded["items"]], ["list", "tuple"])
34
+
35
+ def test_absent_and_empty_prompt(self):
36
+ self.assertEqual(self.program(ir.Literal(None)).to_dict()["config"], {})
37
+ self.assertEqual(self.program(ir.Literal(None), "").to_dict()["config"], {"system_prompt": ""})
38
+
39
+ def test_frozen_nodes_and_invalid_values(self):
40
+ literal = ir.Literal("x")
41
+ with self.assertRaises(FrozenInstanceError):
42
+ literal.value = "y"
43
+ for value in (float("nan"), float("inf"), float("-inf"), [], {}, object()):
44
+ with self.subTest(value=type(value).__name__), self.assertRaises(ValueError):
45
+ ir.Literal(value)
46
+ with self.assertRaises(ValueError):
47
+ self.program(ir.ListExpr([])).to_json()
48
+
49
+ def test_round_trip_preserves_types_and_detaches_data(self):
50
+ original = self.program(ir.DictExpr((
51
+ (ir.Literal("列表"), ir.ListExpr((ir.Literal(None),))),
52
+ (ir.Literal("元组"), ir.TupleExpr((ir.Literal(True), ir.Literal(1.5)))),
53
+ )), "")
54
+ data = original.to_dict()
55
+ restored = ir.Program.from_dict(data)
56
+ self.assertEqual(restored, original)
57
+ self.assertEqual(ir.Program.from_json(original.to_json()), original)
58
+ self.assertIsInstance(restored.body, tuple)
59
+ self.assertIsInstance(restored.body[0].value.entries, tuple)
60
+ data["body"].clear()
61
+ data["config"]["system_prompt"] = "changed"
62
+ self.assertEqual(restored, original)
63
+
64
+
65
+ def test_decode_defaults_and_empty_prompt(self):
66
+ data = self.program(ir.Literal(None)).to_dict()
67
+ del data["body"][0]["source"]["calls"]
68
+ program = ir.Program.from_dict(data)
69
+ self.assertEqual(program.body[0].source.calls, ())
70
+ self.assertEqual(program.to_dict()["config"], {})
71
+ data["config"] = {"system_prompt": ""}
72
+ self.assertEqual(ir.Program.from_dict(data).config.system_prompt, "")
73
+
74
+ def test_decode_reports_document_paths(self):
75
+ base = self.program(ir.Literal(None)).to_dict()
76
+ cases = [
77
+ (("format",), "other", "$.format"),
78
+ (("version",), 2, "$.version"),
79
+ (("version",), True, "$.version"),
80
+ (("version",), "1", "$.version"),
81
+ (("body",), {}, "$.body"),
82
+ (("body", 0, "kind"), "python.eval", "$.body[0].kind"),
83
+ (("body", 0, "kind"), "literal", "$.body[0]"),
84
+ (("body", 0, "value", "value"), [], "$.body[0].value.value"),
85
+ (("body", 0, "value", "value"), float("inf"), "$.body[0].value.value"),
86
+ (("body", 0, "id"), False, "$.body[0].id"),
87
+ (("body", 0, "id"), "", "$.body[0].id"),
88
+ (("config", "system_prompt"), 42, "$.config.system_prompt"),
89
+ (("source", "line"), 0, "$.source"),
90
+ (("source", "column"), True, "$.source.column"),
91
+ (("source", "end_line"), 0, "$.source"),
92
+ ]
93
+ for keys, value, expected_path in cases:
94
+ with self.subTest(keys=keys, value=value):
95
+ data = deepcopy(base)
96
+ parent = data
97
+ for key in keys[:-1]:
98
+ parent = parent[key]
99
+ parent[keys[-1]] = value
100
+ with self.assertRaises(ir.IRDecodeError) as caught:
101
+ ir.Program.from_dict(data)
102
+ self.assertEqual(caught.exception.path, expected_path)
103
+
104
+ def test_decode_rejects_missing_extra_and_duplicate_fields(self):
105
+ data = self.program(ir.Literal(None)).to_dict()
106
+ del data["body"][0]["value"]
107
+ with self.assertRaisesRegex(ir.IRDecodeError, "missing required field"):
108
+ ir.Program.from_dict(data)
109
+ data = self.program(ir.Literal(None)).to_dict()
110
+ data["body"][0]["typo"] = 1
111
+ with self.assertRaisesRegex(ir.IRDecodeError, "unknown fields"):
112
+ ir.Program.from_dict(data)
113
+ with self.assertRaisesRegex(ir.IRDecodeError, "duplicate JSON object key"):
114
+ ir.Program.from_json('{"format":"lev-ir", "format":"other"}')
115
+ data = self.program(ir.Literal(None)).to_dict()
116
+ data["body"].append(deepcopy(data["body"][0]))
117
+ with self.assertRaisesRegex(ir.IRDecodeError, "nonempty and unique"):
118
+ ir.Program.from_dict(data)
119
+
120
+ def test_decode_rejects_invalid_shapes_and_operators(self):
121
+ expressions = [
122
+ ir.DictExpr(((ir.Literal("x"),),)),
123
+ ir.Unary("unknown", ir.Literal(True)),
124
+ ir.Compare(ir.Literal(1), ("eq",), ()),
125
+ ir.Compare(ir.Literal(1), ("exec",), (ir.Literal(2),)),
126
+ ir.BoolOp("and", (ir.Literal(True),)),
127
+ ir.Intrinsic("eval", (ir.Literal("1"),)),
128
+ ir.Intrinsic("json.dumps", (), (("obj", ir.Literal(1)), ("obj", ir.Literal(2)))),
129
+ ir.Field(ir.Var("response"), "__class__"),
130
+ ir.Var(""),
131
+ ]
132
+ for expression in expressions:
133
+ with self.subTest(expression=type(expression).__name__), self.assertRaises(ir.IRDecodeError):
134
+ ir.Program.from_dict(self.program(expression).to_dict())
135
+
136
+ def test_decode_rejects_malformed_json_and_nonfinite_numbers(self):
137
+ with self.assertRaisesRegex(ir.IRDecodeError, "invalid JSON at line"):
138
+ ir.Program.from_json('{broken')
139
+ for number in ("NaN", "Infinity", "-Infinity", "1e999"):
140
+ text = self.program(ir.Literal(None)).to_json().replace('"value": null', f'"value": {number}')
141
+ with self.subTest(number=number), self.assertRaises(ir.IRDecodeError):
142
+ ir.Program.from_json(text)
143
+ with self.assertRaisesRegex(ir.IRDecodeError, "program object"):
144
+ ir.Program.from_json("[]")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ unittest.main()