levain-cli 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.
- levain_cli-0.1.0.dist-info/METADATA +77 -0
- levain_cli-0.1.0.dist-info/RECORD +26 -0
- levain_cli-0.1.0.dist-info/WHEEL +4 -0
- levain_cli-0.1.0.dist-info/entry_points.txt +2 -0
- levain_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- levain_recipe/__init__.py +20 -0
- levain_recipe/__main__.py +6 -0
- levain_recipe/_format.py +45 -0
- levain_recipe/_java.py +95 -0
- levain_recipe/auth.py +322 -0
- levain_recipe/cli.py +344 -0
- levain_recipe/codegen.py +114 -0
- levain_recipe/graph.py +813 -0
- levain_recipe/integrations_check.py +218 -0
- levain_recipe/known_recipes.py +21 -0
- levain_recipe/mcp.py +85 -0
- levain_recipe/models.py +146 -0
- levain_recipe/parser.py +299 -0
- levain_recipe/profile.py +389 -0
- levain_recipe/py.typed +0 -0
- levain_recipe/reducers.py +66 -0
- levain_recipe/source_check.py +279 -0
- levain_recipe/tlc.py +236 -0
- levain_recipe/translate.py +142 -0
- levain_recipe/user_input.py +334 -0
- levain_recipe/validate.py +554 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Validation pipeline: parse -> profile -> graph -> translate -> TLC.
|
|
2
|
+
|
|
3
|
+
Also provides ``validate_package()`` for full recipe package
|
|
4
|
+
validation (PlusCal + Python source + pyproject.toml).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from ._java import resolve_jar
|
|
16
|
+
from .graph import check_graph
|
|
17
|
+
from .integrations_check import check_mcp_names
|
|
18
|
+
from .models import (
|
|
19
|
+
CALL_AGENT_BUILTIN,
|
|
20
|
+
INTERRUPT_BUILTIN,
|
|
21
|
+
INTERRUPTIBLE_CLASSES,
|
|
22
|
+
INTERRUPTIBLE_KEY,
|
|
23
|
+
NodeAnnotation,
|
|
24
|
+
RoutingTable,
|
|
25
|
+
StateField,
|
|
26
|
+
ValidatedSpec,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from .parser import (
|
|
30
|
+
extract_constants,
|
|
31
|
+
extract_defines,
|
|
32
|
+
extract_variables,
|
|
33
|
+
parse,
|
|
34
|
+
)
|
|
35
|
+
from .profile import check_profile
|
|
36
|
+
from .reducers import REDUCERS
|
|
37
|
+
from .source_check import check_python_sources
|
|
38
|
+
from .tlc import model_check
|
|
39
|
+
from .translate import translate
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
44
|
+
|
|
45
|
+
_RE_REDUCER = re.compile(r"\\[*]\s*@reducer\s+(\w+)\s*$")
|
|
46
|
+
# Strip the PlusCal comment prefix ``\*`` (plus surrounding
|
|
47
|
+
# whitespace) from a single-line comment so the recovered body
|
|
48
|
+
# is just the prose the recipe author wrote.
|
|
49
|
+
_RE_COMMENT_PREFIX = re.compile(r"^\\[*]\s?")
|
|
50
|
+
# Any comment line whose body starts with ``@word`` is a
|
|
51
|
+
# structured annotation (``@reducer``, ``@desc``, future tags) —
|
|
52
|
+
# excluded from the free-form description so the help text never
|
|
53
|
+
# accidentally includes machine-readable metadata.
|
|
54
|
+
_RE_ANNOTATION = re.compile(r"^\\[*]\s*@\w+")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate(
|
|
58
|
+
source: str,
|
|
59
|
+
*,
|
|
60
|
+
attached_mcp_names: list[str] | None = None,
|
|
61
|
+
) -> ValidatedSpec | list[ValidationError]:
|
|
62
|
+
"""Full validation pipeline (stages 1-5 + MCP-name check).
|
|
63
|
+
|
|
64
|
+
Runs parse, profile check, graph check, MCP-name resolution,
|
|
65
|
+
PlusCal translation, and TLC model checking. Short-circuits
|
|
66
|
+
on the first stage that fails.
|
|
67
|
+
|
|
68
|
+
``attached_mcp_names`` is forwarded to the MCP-name check —
|
|
69
|
+
see :func:`validate_fast` for its semantics.
|
|
70
|
+
"""
|
|
71
|
+
result = validate_fast(source, attached_mcp_names=attached_mcp_names)
|
|
72
|
+
if isinstance(result, list):
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
translated = translate(source, result.module_name)
|
|
76
|
+
if isinstance(translated, list):
|
|
77
|
+
return translated
|
|
78
|
+
|
|
79
|
+
invariants = result.invariants
|
|
80
|
+
tlc_errors = model_check(translated, result.module_name, invariants)
|
|
81
|
+
if tlc_errors is not None:
|
|
82
|
+
return tlc_errors
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_fast( # noqa: PLR0911
|
|
88
|
+
source: str,
|
|
89
|
+
*,
|
|
90
|
+
attached_mcp_names: list[str] | None = None,
|
|
91
|
+
) -> ValidatedSpec | list[ValidationError]:
|
|
92
|
+
"""Fast validation (stages 1-3 + MCP-name check, no Java).
|
|
93
|
+
|
|
94
|
+
``attached_mcp_names``:
|
|
95
|
+
- ``None`` (default): the MCP-name check reads the target
|
|
96
|
+
list from ``LEVAIN_TARGET_INTEGRATIONS_B64``. Empty/
|
|
97
|
+
missing env var skips the check (clean checkout,
|
|
98
|
+
manual CLI run with no target context).
|
|
99
|
+
- explicit list: enforce against the given names plus
|
|
100
|
+
the platform server names. Pass ``[]`` to enforce
|
|
101
|
+
platform-only.
|
|
102
|
+
"""
|
|
103
|
+
# Stage 1: Parse.
|
|
104
|
+
parse_result = parse(source)
|
|
105
|
+
if isinstance(parse_result, list):
|
|
106
|
+
return parse_result
|
|
107
|
+
|
|
108
|
+
algo, module_name = parse_result
|
|
109
|
+
|
|
110
|
+
# Stage 2: Profile check.
|
|
111
|
+
if algo.parent is None or algo.parent.parent is None:
|
|
112
|
+
return [
|
|
113
|
+
ValidationError(
|
|
114
|
+
stage="profile",
|
|
115
|
+
message=("Unexpected AST structure: missing parent nodes."),
|
|
116
|
+
line=algo.start_point[0] + 1,
|
|
117
|
+
)
|
|
118
|
+
]
|
|
119
|
+
profile_result = check_profile(algo, algo.parent.parent)
|
|
120
|
+
if isinstance(profile_result, list):
|
|
121
|
+
return profile_result
|
|
122
|
+
|
|
123
|
+
annotations, send_fields = profile_result
|
|
124
|
+
|
|
125
|
+
# Extract variables and defines.
|
|
126
|
+
raw_vars = extract_variables(algo)
|
|
127
|
+
variables, var_errors = _build_state_fields(raw_vars)
|
|
128
|
+
if var_errors:
|
|
129
|
+
return var_errors
|
|
130
|
+
|
|
131
|
+
constants = extract_constants(algo)
|
|
132
|
+
invariants = [d for d in extract_defines(algo) if d not in constants]
|
|
133
|
+
|
|
134
|
+
# Stage 3: Graph check. Tell the reachability check that
|
|
135
|
+
# ``interrupt`` nodes count as termination — a chat-shape
|
|
136
|
+
# recipe that loops Respond → WaitForMessage (interrupt) →
|
|
137
|
+
# Respond would otherwise fail "cannot reach Done."
|
|
138
|
+
interrupt_labels = frozenset(
|
|
139
|
+
node.label
|
|
140
|
+
for node in annotations
|
|
141
|
+
if node.function == INTERRUPT_BUILTIN
|
|
142
|
+
)
|
|
143
|
+
graph_result = check_graph(
|
|
144
|
+
algo, raw_vars, send_fields, terminal_labels=interrupt_labels
|
|
145
|
+
)
|
|
146
|
+
if graph_result and isinstance(graph_result[0], ValidationError):
|
|
147
|
+
return graph_result
|
|
148
|
+
routing = cast(list[RoutingTable], graph_result)
|
|
149
|
+
|
|
150
|
+
# Stage 4: MCP-name resolution. Verifies every @mcp_servers
|
|
151
|
+
# entry maps to a known platform server or a target-attached
|
|
152
|
+
# integration; see ``integrations_check`` for the skip rules.
|
|
153
|
+
mcp_errors = check_mcp_names(
|
|
154
|
+
annotations, attached_mcp_names=attached_mcp_names
|
|
155
|
+
)
|
|
156
|
+
if mcp_errors:
|
|
157
|
+
return mcp_errors
|
|
158
|
+
|
|
159
|
+
# Stage 5: agent-to-agent call args. ``call_agent`` nodes need a
|
|
160
|
+
# target slug and an output mapping, or they're a silent no-op.
|
|
161
|
+
a2a_errors = _check_call_agent_nodes(annotations)
|
|
162
|
+
if a2a_errors:
|
|
163
|
+
return a2a_errors
|
|
164
|
+
|
|
165
|
+
# Stage 6: mid-run steering policy. A typo'd ``interruptible``
|
|
166
|
+
# value would otherwise silently fall back to the default and the
|
|
167
|
+
# node would behave unexpectedly under steering.
|
|
168
|
+
steer_errors = _check_interruptible_nodes(annotations)
|
|
169
|
+
if steer_errors:
|
|
170
|
+
return steer_errors
|
|
171
|
+
|
|
172
|
+
return ValidatedSpec(
|
|
173
|
+
source=source,
|
|
174
|
+
module_name=module_name,
|
|
175
|
+
variables=variables,
|
|
176
|
+
nodes=annotations,
|
|
177
|
+
routing=routing,
|
|
178
|
+
invariants=invariants,
|
|
179
|
+
constants=constants,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_call_agent_nodes(
|
|
184
|
+
annotations: list[NodeAnnotation],
|
|
185
|
+
) -> list[ValidationError]:
|
|
186
|
+
"""Validate ``call_agent`` node args (target + I/O).
|
|
187
|
+
|
|
188
|
+
Two shapes are accepted (compiled by the ``call_agent`` dispatch +
|
|
189
|
+
wait nodes):
|
|
190
|
+
|
|
191
|
+
* **Static**: ``agent`` (literal slug), optional ``input`` /
|
|
192
|
+
required ``output`` — flat ``{str: str}`` field maps. The
|
|
193
|
+
compiler reads ``input`` as ``{B_field: A_state_field}`` and
|
|
194
|
+
``output`` as ``{A_state_field: B_output_field}``.
|
|
195
|
+
* **Dynamic**: ``agent_state`` (state field holding the target
|
|
196
|
+
slug), optional ``input_message_state`` (state field holding the
|
|
197
|
+
overlay dict), required ``output_state`` (state field for the
|
|
198
|
+
child's output dict).
|
|
199
|
+
|
|
200
|
+
A missing target or no output sink makes the node a silent
|
|
201
|
+
no-op, so both are hard errors.
|
|
202
|
+
"""
|
|
203
|
+
errors: list[ValidationError] = []
|
|
204
|
+
for node in annotations:
|
|
205
|
+
if node.function != CALL_AGENT_BUILTIN:
|
|
206
|
+
continue
|
|
207
|
+
label = node.label
|
|
208
|
+
errors.extend(_check_one_call_agent(node, label))
|
|
209
|
+
return errors
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _check_one_call_agent(
|
|
213
|
+
node: NodeAnnotation, label: str
|
|
214
|
+
) -> list[ValidationError]:
|
|
215
|
+
errors: list[ValidationError] = []
|
|
216
|
+
|
|
217
|
+
def _nonempty_str(key: str) -> bool:
|
|
218
|
+
val = node.args.get(key)
|
|
219
|
+
return isinstance(val, str) and bool(val)
|
|
220
|
+
|
|
221
|
+
# Target: a literal ``agent`` slug or a dynamic ``agent_state``
|
|
222
|
+
# field name.
|
|
223
|
+
if not _nonempty_str("agent") and not _nonempty_str("agent_state"):
|
|
224
|
+
errors.append(
|
|
225
|
+
ValidationError(
|
|
226
|
+
stage="profile",
|
|
227
|
+
message=(
|
|
228
|
+
f"@node '{label}' uses 'call_agent' but is missing a "
|
|
229
|
+
"target: set 'agent' (a slug) or 'agent_state' (a "
|
|
230
|
+
"state field name)."
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Static input map (optional) must be a string→string map.
|
|
236
|
+
if node.args.get("input") is not None and not _is_str_map(
|
|
237
|
+
node.args.get("input")
|
|
238
|
+
):
|
|
239
|
+
errors.append(
|
|
240
|
+
ValidationError(
|
|
241
|
+
stage="profile",
|
|
242
|
+
message=(
|
|
243
|
+
f"@node '{label}': 'call_agent' 'input' must be an "
|
|
244
|
+
"object mapping field names to field names "
|
|
245
|
+
"(string → string)."
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Output sink: a non-empty static map or a dynamic state field.
|
|
251
|
+
out_map = node.args.get("output")
|
|
252
|
+
has_out_state = _nonempty_str("output_state")
|
|
253
|
+
if out_map is not None and not _is_str_map(out_map):
|
|
254
|
+
errors.append(
|
|
255
|
+
ValidationError(
|
|
256
|
+
stage="profile",
|
|
257
|
+
message=(
|
|
258
|
+
f"@node '{label}': 'call_agent' 'output' must be an "
|
|
259
|
+
"object mapping field names to field names "
|
|
260
|
+
"(string → string)."
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
elif not has_out_state and not (_is_str_map(out_map) and out_map):
|
|
265
|
+
errors.append(
|
|
266
|
+
ValidationError(
|
|
267
|
+
stage="profile",
|
|
268
|
+
message=(
|
|
269
|
+
f"@node '{label}': 'call_agent' has no output sink — "
|
|
270
|
+
"map at least one field via 'output' or set "
|
|
271
|
+
"'output_state', or the call is a no-op."
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
return errors
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _check_interruptible_nodes(
|
|
279
|
+
annotations: list[NodeAnnotation],
|
|
280
|
+
) -> list[ValidationError]:
|
|
281
|
+
"""Validate the optional ``interruptible`` value on each node.
|
|
282
|
+
|
|
283
|
+
Absent is fine (the compiler defaults it); a present value must be
|
|
284
|
+
one of the known steering classes so a typo can't silently degrade
|
|
285
|
+
to the default. ``interrupt`` (Wait) nodes are paused waiting for
|
|
286
|
+
user input and can't be cut mid-flight — the compiler never wires
|
|
287
|
+
steer tracking for them, so setting ``interruptible`` there has no
|
|
288
|
+
effect and is rejected here.
|
|
289
|
+
"""
|
|
290
|
+
errors: list[ValidationError] = []
|
|
291
|
+
for node in annotations:
|
|
292
|
+
if INTERRUPTIBLE_KEY not in node.args:
|
|
293
|
+
continue
|
|
294
|
+
if node.function == INTERRUPT_BUILTIN:
|
|
295
|
+
errors.append(
|
|
296
|
+
ValidationError(
|
|
297
|
+
stage="profile",
|
|
298
|
+
message=(
|
|
299
|
+
f"@node '{node.label}': {INTERRUPTIBLE_KEY!r} is not "
|
|
300
|
+
"valid on 'interrupt' (Wait) nodes — they pause for "
|
|
301
|
+
"user input and are not subject to mid-run steering."
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
val = node.args[INTERRUPTIBLE_KEY]
|
|
307
|
+
if val not in INTERRUPTIBLE_CLASSES:
|
|
308
|
+
errors.append(
|
|
309
|
+
ValidationError(
|
|
310
|
+
stage="profile",
|
|
311
|
+
message=(
|
|
312
|
+
f"@node '{node.label}': {INTERRUPTIBLE_KEY!r} must "
|
|
313
|
+
f"be one of {INTERRUPTIBLE_CLASSES}, got {val!r}."
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
return errors
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _is_str_map(val: object) -> bool:
|
|
321
|
+
return isinstance(val, dict) and all(
|
|
322
|
+
isinstance(k, str) and isinstance(v, str) for k, v in val.items()
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def validate_package(
|
|
327
|
+
recipe_dir: str,
|
|
328
|
+
*,
|
|
329
|
+
attached_mcp_names: list[str] | None = None,
|
|
330
|
+
) -> ValidatedSpec | list[ValidationError]:
|
|
331
|
+
"""Validate a full recipe package (PlusCal + Python source).
|
|
332
|
+
|
|
333
|
+
Reads ``graph.tla`` from *recipe_dir*, runs the PlusCal
|
|
334
|
+
validation pipeline, then checks Python source references.
|
|
335
|
+
|
|
336
|
+
``attached_mcp_names`` is forwarded to the MCP-name check —
|
|
337
|
+
see :func:`validate_fast`.
|
|
338
|
+
"""
|
|
339
|
+
graph_path = os.path.join(recipe_dir, "graph.tla")
|
|
340
|
+
if not os.path.isfile(graph_path):
|
|
341
|
+
return [
|
|
342
|
+
ValidationError(
|
|
343
|
+
stage="source",
|
|
344
|
+
message="graph.tla not found in recipe directory.",
|
|
345
|
+
)
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
with open(graph_path, encoding="utf-8") as f:
|
|
349
|
+
source = f.read()
|
|
350
|
+
|
|
351
|
+
# Stages 1-5 when Java + tla2tools.jar are both present, else
|
|
352
|
+
# 1-3 (fast). The fast fallback is a local-dev convenience —
|
|
353
|
+
# in any container meant to run full validation, set
|
|
354
|
+
# ``LEVAIN_REQUIRE_TLC=1`` so a missing toolchain fails loudly
|
|
355
|
+
# instead of silently shipping a spec that skipped TLC.
|
|
356
|
+
if shutil.which("java") and resolve_jar() is not None:
|
|
357
|
+
result = validate(source, attached_mcp_names=attached_mcp_names)
|
|
358
|
+
elif _require_tlc():
|
|
359
|
+
return [
|
|
360
|
+
ValidationError(
|
|
361
|
+
stage="tlc",
|
|
362
|
+
message=(
|
|
363
|
+
"TLC model-checking is required (LEVAIN_REQUIRE_TLC set)"
|
|
364
|
+
" but the toolchain is missing: "
|
|
365
|
+
f"{_missing_tlc_reason()}."
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
]
|
|
369
|
+
else:
|
|
370
|
+
log.info(
|
|
371
|
+
"validate_package: TLC toolchain unavailable (%s), skipping TLC",
|
|
372
|
+
_missing_tlc_reason(),
|
|
373
|
+
)
|
|
374
|
+
result = validate_fast(source, attached_mcp_names=attached_mcp_names)
|
|
375
|
+
if isinstance(result, list):
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
# Stage 6: Python source checks.
|
|
379
|
+
src_errors = check_python_sources(result, recipe_dir)
|
|
380
|
+
if src_errors:
|
|
381
|
+
return src_errors
|
|
382
|
+
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _require_tlc() -> bool:
|
|
387
|
+
"""Whether deployed services must run full TLC validation.
|
|
388
|
+
|
|
389
|
+
Set in every image that's meant to do full validation
|
|
390
|
+
(harness worker, platform sandbox, recipe service) so a
|
|
391
|
+
regression that strips Java/tla2tools surfaces as a hard
|
|
392
|
+
validation error rather than a silently degraded one.
|
|
393
|
+
"""
|
|
394
|
+
return os.environ.get("LEVAIN_REQUIRE_TLC", "").strip().lower() in _TRUTHY
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _missing_tlc_reason() -> str:
|
|
398
|
+
"""Explain which half of the TLC toolchain is unavailable."""
|
|
399
|
+
if shutil.which("java") is None:
|
|
400
|
+
return "java not found on PATH"
|
|
401
|
+
jar = os.environ.get("TLA2TOOLS_JAR")
|
|
402
|
+
if jar:
|
|
403
|
+
return f"tla2tools.jar not found at TLA2TOOLS_JAR={jar!r}"
|
|
404
|
+
return "tla2tools.jar not found (TLA2TOOLS_JAR not set)"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _build_state_fields(
|
|
408
|
+
raw_vars: list[dict[str, object]],
|
|
409
|
+
) -> tuple[list[StateField], list[ValidationError]]:
|
|
410
|
+
"""Build StateField objects and surface variable-level errors.
|
|
411
|
+
|
|
412
|
+
Checks performed:
|
|
413
|
+
- ``_``-prefixed names collide with platform metadata keys
|
|
414
|
+
(``_error``, ``_cost_usd``) written to node results.
|
|
415
|
+
- Unknown literal types (the parser returned ``"unknown"``
|
|
416
|
+
because the default value wasn't a recognised literal).
|
|
417
|
+
- ``@reducer`` annotation points at a blessed reducer in
|
|
418
|
+
:mod:`levain_recipe.reducers`.
|
|
419
|
+
- ``list``-typed fields must declare a reducer, since the
|
|
420
|
+
default "last write wins" behaviour silently drops
|
|
421
|
+
accumulated items; scalar fields must not (there's no
|
|
422
|
+
reducer in the registry that would compose correctly).
|
|
423
|
+
"""
|
|
424
|
+
errors: list[ValidationError] = []
|
|
425
|
+
fields: list[StateField] = []
|
|
426
|
+
|
|
427
|
+
for v in raw_vars:
|
|
428
|
+
name = str(v["name"])
|
|
429
|
+
vtype = str(v["type"])
|
|
430
|
+
raw_line = v.get("line")
|
|
431
|
+
line = raw_line if isinstance(raw_line, int) else None
|
|
432
|
+
comments = cast(list[str], v.get("comments") or [])
|
|
433
|
+
|
|
434
|
+
if name.startswith("_"):
|
|
435
|
+
errors.append(
|
|
436
|
+
ValidationError(
|
|
437
|
+
stage="profile",
|
|
438
|
+
message=(
|
|
439
|
+
f"Variable '{name}' is reserved"
|
|
440
|
+
" (names prefixed with '_' are reserved"
|
|
441
|
+
" for platform metadata)."
|
|
442
|
+
),
|
|
443
|
+
line=line,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
continue
|
|
447
|
+
if vtype == "unknown":
|
|
448
|
+
errors.append(
|
|
449
|
+
ValidationError(
|
|
450
|
+
stage="profile",
|
|
451
|
+
message=(f"Variable '{name}' has unsupported type."),
|
|
452
|
+
line=line,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
reducer = _extract_reducer(comments)
|
|
459
|
+
except ValueError as exc:
|
|
460
|
+
errors.append(
|
|
461
|
+
ValidationError(
|
|
462
|
+
stage="profile",
|
|
463
|
+
message=f"Variable '{name}': {exc}",
|
|
464
|
+
line=line,
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
continue
|
|
468
|
+
if reducer is not None and reducer not in REDUCERS:
|
|
469
|
+
errors.append(
|
|
470
|
+
ValidationError(
|
|
471
|
+
stage="profile",
|
|
472
|
+
message=(
|
|
473
|
+
f"Unknown @reducer '{reducer}' on variable"
|
|
474
|
+
f" '{name}'. Known:"
|
|
475
|
+
f" {', '.join(sorted(REDUCERS))}."
|
|
476
|
+
),
|
|
477
|
+
line=line,
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
continue
|
|
481
|
+
if vtype == "list" and reducer is None:
|
|
482
|
+
errors.append(
|
|
483
|
+
ValidationError(
|
|
484
|
+
stage="profile",
|
|
485
|
+
message=(
|
|
486
|
+
f"List variable '{name}' must declare a"
|
|
487
|
+
" @reducer — the default last-write-wins"
|
|
488
|
+
" behaviour drops accumulated items."
|
|
489
|
+
),
|
|
490
|
+
line=line,
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
continue
|
|
494
|
+
if vtype != "list" and reducer is not None:
|
|
495
|
+
errors.append(
|
|
496
|
+
ValidationError(
|
|
497
|
+
stage="profile",
|
|
498
|
+
message=(
|
|
499
|
+
f"@reducer is only valid on list variables"
|
|
500
|
+
f" (got '{vtype}' on '{name}')."
|
|
501
|
+
),
|
|
502
|
+
line=line,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
fields.append(
|
|
508
|
+
StateField(
|
|
509
|
+
name=name,
|
|
510
|
+
type=vtype,
|
|
511
|
+
default=v["default"],
|
|
512
|
+
reducer=reducer,
|
|
513
|
+
description=_extract_description(comments),
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
return fields, errors
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _extract_description(comments: list[str]) -> str | None:
|
|
521
|
+
r"""Return the free-form description from the comment list.
|
|
522
|
+
|
|
523
|
+
Joins every comment line that is *not* an ``@``-prefixed
|
|
524
|
+
annotation, stripping the leading ``\*`` prefix and
|
|
525
|
+
collapsing the result into one paragraph. Returns ``None``
|
|
526
|
+
when the variable has no descriptive comments.
|
|
527
|
+
"""
|
|
528
|
+
parts: list[str] = []
|
|
529
|
+
for text in comments:
|
|
530
|
+
if _RE_ANNOTATION.match(text):
|
|
531
|
+
continue
|
|
532
|
+
body = _RE_COMMENT_PREFIX.sub("", text).strip()
|
|
533
|
+
if body:
|
|
534
|
+
parts.append(body)
|
|
535
|
+
if not parts:
|
|
536
|
+
return None
|
|
537
|
+
return " ".join(parts)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _extract_reducer(comments: list[str]) -> str | None:
|
|
541
|
+
"""Return the ``@reducer <name>`` from the comment list, or None.
|
|
542
|
+
|
|
543
|
+
Raises ``ValueError`` if more than one ``@reducer`` annotation
|
|
544
|
+
is present — the caller wraps it in a ``ValidationError`` so
|
|
545
|
+
duplicates surface instead of being silently dropped.
|
|
546
|
+
"""
|
|
547
|
+
matches = [
|
|
548
|
+
m.group(1) for text in comments if (m := _RE_REDUCER.match(text))
|
|
549
|
+
]
|
|
550
|
+
if len(matches) > 1:
|
|
551
|
+
raise ValueError(
|
|
552
|
+
f"multiple @reducer annotations {matches}; declare at most one."
|
|
553
|
+
)
|
|
554
|
+
return matches[0] if matches else None
|