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
annoscript/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """AnnoScript's Python eDSL, compiler, execution plans and API-backed runtime."""
@@ -0,0 +1,17 @@
1
+ """Generation wire profiles shared by configuration readers; no backend imports."""
2
+
3
+ GENERATION_PROFILES = ("legacy", "qwen_vllm")
4
+
5
+
6
+ def validate_extract_mode(mode, profile):
7
+ if type(mode) is not str or mode not in ("validated", "substring_chars"):
8
+ raise ValueError("extract_mode must be validated or substring_chars")
9
+ if mode == "substring_chars" and profile != "qwen_vllm":
10
+ raise ValueError("substring_chars requires qwen_vllm")
11
+ return mode
12
+
13
+
14
+ def validate_generation_profile(profile):
15
+ if type(profile) is not str or profile not in GENERATION_PROFILES:
16
+ raise ValueError("generation profile must be legacy or qwen_vllm")
17
+ return profile
@@ -0,0 +1,287 @@
1
+ """Python 3.10+ contracts shared by local and remote clients; no execution imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable, Iterator
7
+ from dataclasses import dataclass
8
+ from types import FunctionType, TracebackType
9
+ from typing import Generic, List, Literal, TypeAlias, TypeVar
10
+ from weakref import WeakKeyDictionary
11
+
12
+ from annoscript.anno import Anno
13
+
14
+
15
+ JSONValue: TypeAlias = (
16
+ str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
17
+ )
18
+ State: TypeAlias = str | list[JSONValue] | dict[str, JSONValue]
19
+ R = TypeVar("R", bound=JSONValue)
20
+ InputCheck: TypeAlias = Callable[[State], None]
21
+
22
+ # Keep local checks outside the program function's attributes and execution body.
23
+ _PRECONDITIONS: WeakKeyDictionary[
24
+ Callable[[Anno], JSONValue], tuple[InputCheck, ...]
25
+ ] = WeakKeyDictionary()
26
+ # Only this registry's text values are exported as remote program configuration.
27
+ _SYSTEM_PROMPTS: WeakKeyDictionary[Callable[[Anno], JSONValue], str] = WeakKeyDictionary()
28
+
29
+
30
+ class InputValidationError(ValueError):
31
+ """An input failed a client-local precondition, before remote submission."""
32
+
33
+
34
+ def require(condition: bool, *, message: str) -> None:
35
+ """Check an input condition locally; raise InputValidationError on failure.
36
+
37
+ This ordinary Python function remains active under optimization. It never
38
+ invokes inference, repairs inputs, or propagates constraints to the eDSL.
39
+ """
40
+ if not condition:
41
+ raise InputValidationError(message)
42
+
43
+
44
+ def precondition(
45
+ check: InputCheck,
46
+ ) -> Callable[[Callable[[Anno], R]], Callable[[Anno], R]]:
47
+ """Register a client-local input check and return the original program.
48
+
49
+ Checks are synchronous, deterministic, read-only Python functions. Return
50
+ None for success and raise InputValidationError for an invalid input. Other
51
+ exceptions indicate a checker defect and abort submission. Checks must not
52
+ invoke models or make network requests.
53
+
54
+ Registration does not run checks or wrap the program. Direct program calls
55
+ do not run checks; AnnoClient implementations run them before any remote
56
+ submission. Stacked decorators are checked in source order (top to bottom),
57
+ stopping at the first failed check for each input.
58
+
59
+ The registry is local only. Remote program packaging must exclude these
60
+ decorator declarations, check functions, and their validation dependencies.
61
+ """
62
+ if not callable(check):
63
+ raise TypeError("precondition requires a callable input check")
64
+
65
+ def register(program: Callable[[Anno], R]) -> Callable[[Anno], R]:
66
+ if not isinstance(program, FunctionType):
67
+ raise TypeError("precondition must decorate a Python function")
68
+ _PRECONDITIONS[program] = (check,) + _PRECONDITIONS.get(program, ())
69
+ return program
70
+
71
+ return register
72
+
73
+
74
+ def system_prompt(
75
+ prompt: str,
76
+ ) -> Callable[[Callable[[Anno], R]], Callable[[Anno], R]]:
77
+ """Declare shared model instructions and return the original program.
78
+
79
+ The client exports this text as program configuration. The runtime binds it
80
+ to every record's injected AnnoScript and applies it to LLM operations, including
81
+ calls inside helper functions. Decision requests use their own instructions
82
+ and requirements. API guarantees and hard constraints remain independently
83
+ enforced. Mapping shared instructions to a model is the runtime's job.
84
+
85
+ Registration neither wraps nor executes the program and creates no context.
86
+ Direct Python calls do not inject the prompt. Each program may declare one
87
+ system prompt; any repeated declaration raises ValueError. Empty strings
88
+ are allowed and preserved. Prompt text is sent as configuration, without
89
+ executing the decorator remotely or exporting local precondition checks.
90
+ """
91
+ if not isinstance(prompt, str):
92
+ raise TypeError("system_prompt requires a string")
93
+
94
+ def register(program: Callable[[Anno], R]) -> Callable[[Anno], R]:
95
+ if not isinstance(program, FunctionType):
96
+ raise TypeError("system_prompt must decorate a Python function")
97
+ if program in _SYSTEM_PROMPTS:
98
+ raise ValueError("a program may declare only one system_prompt")
99
+ _SYSTEM_PROMPTS[program] = prompt
100
+ return program
101
+
102
+ return register
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class ResultError:
107
+ """A per-input failure, including local validation or unsatisfied constraints."""
108
+
109
+ code: str
110
+ message: str
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Result(Generic[R]):
115
+ """One terminal outcome, identified by its zero-based input index.
116
+
117
+ Success preserves the program's return value, including ``None``. An error
118
+ carries no value. A list-valued result stays one result, not multiple items.
119
+ """
120
+
121
+ index: int
122
+ status: Literal["success", "error"]
123
+ value: R | None = None
124
+ error: ResultError | None = None
125
+
126
+ def __post_init__(self) -> None:
127
+ if type(self.index) is not int or self.index < 0:
128
+ raise ValueError("index must be a nonnegative integer")
129
+ if self.status == "success":
130
+ if self.error is not None:
131
+ raise ValueError("a successful result cannot contain an error")
132
+ elif self.status == "error":
133
+ if not isinstance(self.error, ResultError) or self.value is not None:
134
+ raise ValueError("an error result requires ResultError and no value")
135
+ else:
136
+ raise ValueError("status must be 'success' or 'error'")
137
+
138
+
139
+ class ResultStream(Iterator[Result[R]], ABC):
140
+ """A single-pass result iterator with explicit resource ownership.
141
+
142
+ Use ``with`` when stopping early: breaking a plain ``for`` loop does not
143
+ close the stream. Exhaustion and iteration errors automatically close it.
144
+ Concrete transports implement ``_next_result`` and ``_close`` and call
145
+ ``super().__init__()`` when overriding construction.
146
+
147
+ Streams are intended for consumption and closure by a single thread.
148
+ """
149
+
150
+ def __init__(self) -> None:
151
+ self._closed = False
152
+
153
+ @property
154
+ def closed(self) -> bool:
155
+ return self._closed
156
+
157
+ def __iter__(self) -> ResultStream[R]:
158
+ return self
159
+
160
+ def __next__(self) -> Result[R]:
161
+ if self._closed:
162
+ raise StopIteration
163
+ try:
164
+ return self._next_result()
165
+ except StopIteration:
166
+ self.close()
167
+ raise
168
+ except BaseException:
169
+ try:
170
+ self.close()
171
+ except BaseException:
172
+ import logging
173
+ logging.getLogger(__name__).warning("Result cleanup failed while handling another exception")
174
+ raise
175
+
176
+ def close(self) -> None:
177
+ """Stop receiving results and release resources; repeated calls are harmless.
178
+
179
+ Closing discards undelivered results. No further work may be submitted.
180
+ The transport should request cancellation of pending
181
+ work, without promising that already-running remote work can be stopped.
182
+ The stream stays closed even if transport cleanup raises an exception.
183
+ """
184
+ if not self._closed:
185
+ self._closed = True
186
+ self._close()
187
+
188
+ def __enter__(self) -> ResultStream[R]:
189
+ if self._closed:
190
+ raise RuntimeError("cannot enter a closed result stream")
191
+ return self
192
+
193
+ def __exit__(
194
+ self,
195
+ exc_type: type[BaseException] | None,
196
+ exc_value: BaseException | None,
197
+ traceback: TracebackType | None,
198
+ ) -> Literal[False]:
199
+ if exc_type is None:
200
+ self.close()
201
+ else:
202
+ try:
203
+ self.close()
204
+ except BaseException:
205
+ import logging
206
+ logging.getLogger(__name__).warning("Result cleanup failed while handling another exception")
207
+ return False
208
+
209
+ @abstractmethod
210
+ def _next_result(self) -> Result[R]:
211
+ """Return the next result, or raise StopIteration when fully exhausted.
212
+
213
+ Return per-input failures as error results. Raise submission-level or
214
+ transport failures as exceptions; the stream will then close itself.
215
+ """
216
+ raise NotImplementedError
217
+
218
+ @abstractmethod
219
+ def _close(self) -> None:
220
+ """Release local resources and request cancellation, at most once.
221
+
222
+ This hook must not submit new work or wait for unfinished work to
223
+ complete. It is also called after normal exhaustion or iteration errors.
224
+ """
225
+ raise NotImplementedError
226
+
227
+
228
+ def get_program_config(program: Callable[[Anno], R]) -> dict[str, JSONValue]:
229
+ """Return fresh, JSON-compatible execution settings for remote submission.
230
+
231
+ Export system-prompt text only, never local check functions or rules.
232
+ Programs without a declaration have no system_prompt configuration key.
233
+ Reading configuration does not execute the program or its preconditions.
234
+ """
235
+ prompt = _SYSTEM_PROMPTS.get(program)
236
+ return {} if prompt is None else {"system_prompt": prompt}
237
+
238
+
239
+ def validate_inputs(
240
+ program: Callable[[Anno], R], inputs: List[State]
241
+ ) -> dict[int, ResultError]:
242
+ """Run the whole batch's preconditions locally, without running program.
243
+
244
+ Call before any remote submission. Return invalid-input errors keyed by
245
+ original list index; the remaining indices passed all registered checks.
246
+ Only InputValidationError becomes an item error. Unexpected exceptions
247
+ and non-None check returns abort validation and must prevent submission.
248
+
249
+ This helper validates the outer list and program-specific preconditions.
250
+ Concrete clients also validate the generic JSON input contract.
251
+ """
252
+ if not isinstance(inputs, list):
253
+ raise TypeError("inputs must be a list")
254
+ checks = _PRECONDITIONS.get(program, ())
255
+ errors = {}
256
+ for index, state in enumerate(inputs):
257
+ try:
258
+ for check in checks:
259
+ if check(state) is not None:
260
+ raise TypeError("precondition checks must return None on success")
261
+ except InputValidationError as error:
262
+ errors[index] = ResultError(code="invalid_input", message=str(error))
263
+ return errors
264
+
265
+
266
+ def prepare_inputs(program, inputs):
267
+ """Snapshot JSON inputs, then check all valid shapes before any submission.
268
+
269
+ Return (indexed states, local errors), retaining the original input indices.
270
+ Unexpected checker exceptions propagate before either client dispatches work.
271
+ """
272
+ from annoscript._data import snapshot
273
+
274
+ if not isinstance(inputs, list):
275
+ raise TypeError("inputs must be a list")
276
+ errors, valid = {}, []
277
+ for index, state in enumerate(inputs):
278
+ try:
279
+ valid.append((index, snapshot(state, state=True)))
280
+ except (ValueError, RecursionError):
281
+ errors[index] = ResultError(
282
+ "invalid_input",
283
+ "input must contain finite JSON data and have string, array or object shape",
284
+ )
285
+ checked = validate_inputs(program, [state for _, state in valid])
286
+ errors.update({valid[i][0]: error for i, error in checked.items()})
287
+ return [item for i, item in enumerate(valid) if i not in checked], errors
annoscript/_data.py ADDED
@@ -0,0 +1,37 @@
1
+ """JSON snapshots and immutable inference payloads; no application code runs here."""
2
+
3
+ import math
4
+ from types import MappingProxyType
5
+ from collections.abc import Mapping
6
+
7
+
8
+ def snapshot(value, path="$", *, state=False):
9
+ if state and type(value) not in (str, list, dict):
10
+ raise ValueError(f"{path}: input must be a string, list or object")
11
+ if value is None or type(value) in (str, bool, int):
12
+ return value
13
+ if type(value) is float and math.isfinite(value):
14
+ return value
15
+ if type(value) is list:
16
+ return [snapshot(v, f"{path}[{i}]") for i, v in enumerate(value)]
17
+ if type(value) is dict:
18
+ if any(type(k) is not str for k in value):
19
+ raise ValueError(f"{path}: object keys must be strings")
20
+ return {k: snapshot(v, f"{path}.{k}") for k, v in value.items()}
21
+ raise ValueError(f"{path}: expected finite JSON data")
22
+
23
+
24
+ def freeze(value):
25
+ if isinstance(value, Mapping):
26
+ return MappingProxyType({k: freeze(v) for k, v in value.items()})
27
+ if isinstance(value, (list, tuple)):
28
+ return tuple(freeze(v) for v in value)
29
+ return value
30
+
31
+
32
+ def thaw(value):
33
+ if isinstance(value, Mapping):
34
+ return {k: thaw(v) for k, v in value.items()}
35
+ if isinstance(value, tuple):
36
+ return [thaw(v) for v in value]
37
+ return value
@@ -0,0 +1,47 @@
1
+ """Event-loop-local, FIFO model slots shared by isolated input runtimes.
2
+
3
+ Waiters contain owner identities and wake callbacks, never model calls or payloads.
4
+ Batch selection therefore happens after admission, using the current ready set.
5
+ """
6
+ from collections import OrderedDict
7
+
8
+
9
+ class RequestSlots:
10
+ def __init__(self, *, generation, decision):
11
+ if any(type(v) is not int or v < 1 for v in (generation, decision)):
12
+ raise ValueError("shared model limits must be positive integers")
13
+ self.limits = {"llm": generation, "decision": decision}
14
+ self.active = dict.fromkeys(self.limits, 0)
15
+ self.peak = dict(self.active)
16
+ self.waiters = {k: OrderedDict() for k in self.limits}
17
+
18
+ def acquire(self, kind, owner, wake):
19
+ waiting = self.waiters[kind]
20
+ waiting.setdefault(owner, wake)
21
+ if self.active[kind] >= self.limits[kind] or next(iter(waiting)) != owner:
22
+ return None
23
+ waiting.pop(owner)
24
+ self.active[kind] += 1
25
+ self.peak[kind] = max(self.peak[kind], self.active[kind])
26
+ self._wake(kind)
27
+ released = False
28
+
29
+ def release():
30
+ nonlocal released
31
+ if not released:
32
+ released = True
33
+ self.active[kind] -= 1
34
+ self._wake(kind)
35
+ return release
36
+
37
+ def _wake(self, kind):
38
+ for wake in list(self.waiters[kind].values())[:self.limits[kind] - self.active[kind]]:
39
+ wake()
40
+
41
+ def cancel(self, owner):
42
+ for kind in self.limits:
43
+ if self.waiters[kind].pop(owner, None) is not None:
44
+ self._wake(kind)
45
+
46
+ def waiting(self, owner):
47
+ return any(owner in q for q in self.waiters.values())
annoscript/_env.py ADDED
@@ -0,0 +1,19 @@
1
+ """Canonical ANNO configuration with legacy LEV input compatibility."""
2
+
3
+ import os
4
+
5
+
6
+ def normalize(values):
7
+ """Normalize one configuration layer before merging higher-priority layers."""
8
+ result = dict(values)
9
+ for name, value in values.items():
10
+ if name.startswith("LEV_"):
11
+ result.setdefault("ANNO_" + name[4:], value)
12
+ return result
13
+
14
+
15
+ def env_value(name, default=None, *, environ=None):
16
+ values = os.environ if environ is None else environ
17
+ if name in values:
18
+ return values[name]
19
+ return values.get("LEV_" + name[5:], default)
@@ -0,0 +1,34 @@
1
+ """Portable example data; this module has no runtime/backend dependencies."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from annoscript._data import freeze, snapshot, thaw
6
+
7
+
8
+ def example_data(state, answer):
9
+ """The closed ``example`` intrinsic: return detached, finite JSON data."""
10
+ return {"state": snapshot(state, state=True), "answer": snapshot(answer)}
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Example:
15
+ """An input and its task answer, using the enclosing operation's parameters.
16
+
17
+ Answers contain only the business value, never confidence or probabilities.
18
+ Nested values are immutable snapshots. Compiled examples carry ordinary JSON,
19
+ not Python objects. Task/criteria overrides are intentionally unsupported.
20
+ """
21
+
22
+ state: object
23
+ answer: object
24
+
25
+ def __post_init__(self):
26
+ data = example_data(self.state, self.answer)
27
+ object.__setattr__(self, "state", freeze(data["state"]))
28
+ object.__setattr__(self, "answer", freeze(data["answer"]))
29
+
30
+ def to_dict(self):
31
+ return {"state": thaw(self.state), "answer": thaw(self.answer)}
32
+
33
+ def __deepcopy__(self, memo):
34
+ return self
@@ -0,0 +1,93 @@
1
+ """Closed expression interpreter for the runtime's value bindings."""
2
+
3
+ import json
4
+ import operator
5
+
6
+ from annoscript import ir
7
+ from annoscript._examples import example_data
8
+ from annoscript.backends.base import RESPONSE_TYPES
9
+
10
+
11
+ _COMPARE = {
12
+ "eq": operator.eq, "ne": operator.ne, "lt": operator.lt, "le": operator.le,
13
+ "gt": operator.gt, "ge": operator.ge, "in": lambda a, b: a in b,
14
+ "not_in": lambda a, b: a not in b, "is": operator.is_, "is_not": operator.is_not,
15
+ }
16
+
17
+
18
+ def expression(node, env, state, allocations=None):
19
+ ev = lambda e: expression(e, env, state, allocations)
20
+ def allocated(value):
21
+ if allocations is not None and type(value) in (list, dict):
22
+ allocations[id(value)] = value
23
+ return value
24
+ if isinstance(node, ir.Literal):
25
+ return node.value
26
+ if isinstance(node, ir.Var):
27
+ return env[node.name]
28
+ if isinstance(node, ir.State):
29
+ return state
30
+ if isinstance(node, ir.GetItem):
31
+ return ev(node.value)[ev(node.index)]
32
+ if isinstance(node, ir.Field):
33
+ value = ev(node.value)
34
+ if type(value) not in RESPONSE_TYPES.values() or node.name not in value.__dataclass_fields__:
35
+ raise ValueError("field access requires a response field")
36
+ return getattr(value, node.name)
37
+ if isinstance(node, ir.ListExpr):
38
+ return allocated([ev(x) for x in node.items])
39
+ if isinstance(node, ir.TupleExpr):
40
+ return tuple(ev(x) for x in node.items)
41
+ if isinstance(node, ir.DictExpr):
42
+ return allocated({ev(k): ev(v) for k, v in node.entries})
43
+ if isinstance(node, ir.Unary):
44
+ return {"not": operator.not_, "negative": operator.neg, "positive": operator.pos}[node.op](ev(node.value))
45
+ if isinstance(node, ir.Binary):
46
+ return allocated(ev(node.left) + ev(node.right))
47
+ if isinstance(node, ir.Compare):
48
+ left = ev(node.left)
49
+ for op, other in zip(node.ops, node.comparators):
50
+ right = ev(other)
51
+ if not _COMPARE[op](left, right):
52
+ return False
53
+ left = right
54
+ return True
55
+ if isinstance(node, ir.BoolOp):
56
+ value = ev(node.values[0])
57
+ for other in node.values[1:]:
58
+ if (node.op == "and" and not value) or (node.op == "or" and value):
59
+ break
60
+ value = ev(other)
61
+ return value
62
+ if isinstance(node, ir.Intrinsic):
63
+ fn = {"len": len, "int": int, "range": range, "enumerate": enumerate,
64
+ "str.strip": str.strip, "json.dumps": json.dumps,
65
+ "example": example_data}[node.name]
66
+ return fn(*[ev(x) for x in node.args], **{k: ev(v) for k, v in node.kwargs})
67
+ if isinstance(node, ir.FormatValue):
68
+ value = ev(node.value)
69
+ spec = ev(node.spec) if node.spec is not None else ""
70
+ if node.conversion != "none":
71
+ value = {"s": str, "r": repr, "a": ascii}[node.conversion](value)
72
+ return format(value, spec)
73
+ if isinstance(node, ir.FormatString):
74
+ return "".join(ev(x) for x in node.parts)
75
+ raise ValueError("unsupported expression")
76
+
77
+
78
+ def unpack(target, value):
79
+ if isinstance(target, ir.NameTarget):
80
+ return [value]
81
+ # Consume at most one extra item, just like exact Python unpacking.
82
+ it = iter(value)
83
+ values = []
84
+ for _ in target.items:
85
+ try:
86
+ values.append(next(it))
87
+ except StopIteration:
88
+ raise ValueError("not enough values to unpack") from None
89
+ try:
90
+ next(it)
91
+ except StopIteration:
92
+ return [v for t, item in zip(target.items, values) for v in unpack(t, item)]
93
+ raise ValueError("too many values to unpack")