Skip to content

Commit ce593cd

Browse files
authored
refactor(workflows): let the evaluator report its own leaves (#4274) (#4460)
* refactor(workflows): let the evaluator report its own leaves (#4274) _unresolvable_term answered one question -- does every operand in this condition resolve to something? -- by walking the expression itself: filters, then or/and/not, then comparisons, then list literals, down to the leaves. That walk was a second implementation of the parsing in _evaluate_simple_expression, kept in step with it by hand. Two helpers existed only to restate rules the evaluator already had. _looks_numeric mirrored the float()-only-when-a-dot-is-present rule because a bare float() accepts 1e3 and the evaluator does not. _is_literal mirrored the matching-close-is-the-final-character string test because startswith/endswith accepts 'a' 'b' and the evaluator does not. Both docstrings said "mirror the evaluator exactly", which is the tell: when the two drift nothing breaks loudly, the gate just answers wrongly, and the wrong answer is a paste-ready correction that inverts a condition. Seven of the nine findings in #4230 were the same defect wearing different clothes -- the gate disagreeing with the evaluator about where the operands are. Each round fixed one shape. Nothing stopped a tenth. _evaluate_simple_expression has exactly one place where a substring stops being grammar and becomes a name to resolve: its final line, _resolve_dot_path. Literals return before it; operands, filter arguments and list elements all arrive there by construction. Record the leaf there, behind a ContextVar that is None outside a probe, and the gate applies namespace rules to that list instead of re-deriving it. It now contains no grammar at all. Two properties this rests on, both asserted rather than assumed: * or/and are not short-circuited -- both sides are evaluated and only then combined -- so a leaf is recorded whatever the other side is worth. If that ever changes the gate would go quietly blind, so there is a test for it. * A probe run can raise on its own placeholder values. The leaves seen before that point are real, so they are kept rather than discarded; discarding them would lose `bogus` in `inputs.tags | join(bogus)`, which an earlier round of #4230 had to add by hand. expressions.py is 109 lines lighter and 84 heavier. All 336 existing tests pass unchanged, including the 20 cases of test_operands_must_be_literals_or_known_paths that took eight rounds to get right. test_literal_test_mirrors_the_evaluator tested the mirror, so it becomes test_literal_handling_comes_from_the_evaluator and asserts the same knowledge about 1e3 and 'a' 'b' through the gate instead. Four mutations, each killed by the tests that should kill it -- removing the leaf report alone turns 38 red. ruff 0.15.0 clean. * refactor(workflows): let _resolve_dot_path define the indexed segment The gate no longer restates the operator grammar, but it still restated the shape of a path segment: _PATH_SEGMENT and an inline fullmatch both described the index form that _resolve_dot_path matches with its own regex. Three copies of one rule, kept in step by hand -- the same drift this refactor set out to remove, one layer down. Name the form once as _INDEXED_SEGMENT beside _resolve_dot_path and have the gate ask it. Behaviour is unchanged: the regex is copied verbatim. What changes is that widening indexing now reaches the gate for free. * test(workflows): pin that the gate reads the evaluator's definitions Two regression tests for the property this refactor is for, both of which a second copy of the grammar in the gate would break while every existing test stayed green: - widening _INDEXED_SEGMENT alone reaches the gate (the negative-index shape from #4416) - when the evaluator stops treating something as a leaf, the gate stops checking it, with no gate edit (the grouped-operand shape from #4417) Both were checked by reintroducing the drift: giving the gate its own segment regex again fails the first with the real message rather than an import error. * fix(workflows): keep collecting leaves after a probe error The refactor stopped the leaf walk at the first exception a probe value raised, so every leaf further along the chain was lost. That is the one thing the collection exists to report, and it was a step backwards from the hand-written walk this PR replaces: inputs.blob | from_json | contains(bogus) origin/main reports 'bogus' this PR before the fix MISSED this PR after the fix reports 'bogus' from_json receives the probe placeholder mapping and raises; the walk ended there and contains(bogus) was never reached. Carry on past a failing filter while the sink is armed. _apply_filter evaluates a filter argument before it can raise on the value, so the failing segment's own leaves are already recorded; a fresh placeholder goes into the next filter, matching what the probe namespace hands out. Scoped to the probe: the sink is armed only by _collect_leaves, and _evaluator_rejects runs its own probe without it, so a mis-wired filter is still rejected and a real evaluation still raises rather than quietly returning the unfiltered value.
1 parent 7cb2c7d commit ce593cd

2 files changed

Lines changed: 264 additions & 116 deletions

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 104 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import json
1111
import re
12+
from contextvars import ContextVar
1213
from typing import Any
1314

1415

@@ -134,6 +135,15 @@ def _filter_from_json(value: Any) -> Any:
134135
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")
135136

136137

138+
# The one definition of an indexed path segment. _resolve_dot_path matches
139+
# against it, and the condition gate below reuses it rather than describing the
140+
# same shape a second time, so widening what indexing accepts cannot leave the
141+
# evaluator and the gate disagreeing.
142+
_INDEXED_SEGMENT = re.compile(r"^([\w-]+)\[(\d+)\]$")
143+
144+
_PLAIN_SEGMENT = re.compile(r"^[\w-]+$")
145+
146+
137147
def _resolve_dot_path(obj: Any, path: str) -> Any:
138148
"""Resolve a dotted path like ``steps.specify.output.file`` against *obj*.
139149
@@ -143,7 +153,7 @@ def _resolve_dot_path(obj: Any, path: str) -> Any:
143153
current = obj
144154
for part in parts:
145155
# Handle list indexing: name[0]
146-
idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part)
156+
idx_match = _INDEXED_SEGMENT.match(part)
147157
if idx_match:
148158
key, idx = idx_match.group(1), int(idx_match.group(2))
149159
if isinstance(current, dict):
@@ -479,6 +489,11 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
479489
# evaluator will actually split on.
480490
_COMPARISON_OPERATORS = ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ")
481491

492+
# Set only while `_collect_leaves` probes an expression; None everywhere else, so
493+
# a normal evaluation costs one `.get()`. A ContextVar rather than a module global
494+
# so concurrent probes cannot append into each other's list.
495+
_leaf_sink: ContextVar[list[str] | None] = ContextVar("_leaf_sink", default=None)
496+
482497

483498
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
484499
"""Evaluate a simple expression against the namespace.
@@ -545,8 +560,27 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
545560
f"operand in its own expression instead."
546561
)
547562
value = _evaluate_simple_expression(head, namespace)
563+
sink = _leaf_sink.get()
548564
for segment in segments[1:]:
549-
value = _apply_filter(value, segment.strip(), namespace)
565+
if sink is None:
566+
value = _apply_filter(value, segment.strip(), namespace)
567+
continue
568+
# Probing. A filter handed a placeholder can raise on it -- from_json
569+
# on a mapping is the common one -- and letting that end the walk
570+
# hides every leaf further along the chain, which is the one thing
571+
# this collection exists to report: `inputs.blob | from_json |
572+
# contains(bogus)` recorded `inputs.blob` and stopped, so `bogus`
573+
# was never offered to _unresolvable_leaf. _apply_filter evaluates a
574+
# filter's argument before it can raise on the value, so the leaves
575+
# of the failing segment are already recorded when we get here.
576+
# Carry a fresh placeholder so the next filter sees the same kind of
577+
# unknown the namespace hands out. Real evaluation is untouched: the
578+
# sink is armed only by _collect_leaves, and _evaluator_rejects runs
579+
# its own probe without it, so a mis-wired filter is still reported.
580+
try:
581+
value = _apply_filter(value, segment.strip(), namespace)
582+
except Exception: # noqa: BLE001 - probe values, not the author's text
583+
value = _ProbeNamespace()
550584
return value
551585

552586
# Boolean operators — parse 'or' first (lower precedence) so that
@@ -627,7 +661,12 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
627661
]
628662
return items
629663

630-
# Variable reference (dot-path)
664+
# Variable reference (dot-path). This is the one place a substring stops being
665+
# grammar and becomes a name to resolve, so it is where a probe can learn what
666+
# the evaluator will actually look up. Literals have all returned above.
667+
sink = _leaf_sink.get()
668+
if sink is not None:
669+
sink.append(expr)
631670
return _resolve_dot_path(namespace, expr)
632671

633672

@@ -1047,8 +1086,9 @@ def _has_incomplete_operand(text: str) -> bool:
10471086
# None, so a correction built on one turns a truthy condition false.
10481087
_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context")
10491088

1050-
# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index.
1051-
_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$")
1089+
def _is_path_segment(segment: str) -> bool:
1090+
"""Whether _resolve_dot_path can walk *segment*: a name, or a name it indexes."""
1091+
return bool(_PLAIN_SEGMENT.match(segment) or _INDEXED_SEGMENT.match(segment))
10521092

10531093

10541094
class _ProbeNamespace(dict):
@@ -1097,123 +1137,51 @@ def _evaluator_rejects(text: str) -> str | None:
10971137

10981138

10991139

1100-
def _looks_numeric(text: str) -> bool:
1101-
"""Mirror the evaluator's numeric literal test exactly.
1102-
1103-
`_evaluate_simple_expression` only calls `float()` when a `.` is present and
1104-
`int()` otherwise, so `1e3` is not a number to it -- it falls through to a path
1105-
lookup and resolves to None. A bare `float()` here accepted `1e3` and the
1106-
correction turned a truthy condition false.
1107-
"""
1108-
try:
1109-
if "." in text:
1110-
float(text)
1111-
else:
1112-
int(text)
1113-
except (ValueError, TypeError):
1114-
return False
1115-
return True
1116-
1140+
def _collect_leaves(text: str) -> list[str]:
1141+
"""Every substring *text* hands to the evaluator as a name to resolve.
11171142
1118-
def _is_literal(text: str) -> bool:
1119-
"""Mirror the evaluator's literal tests exactly.
1143+
Runs the same probe ``_evaluator_rejects`` uses, with the leaf sink armed.
1144+
Literals never reach the dot-path resolution, and operands, filter arguments
1145+
and list elements all do -- ``_evaluate_simple_expression`` evaluates both
1146+
sides of ``or``/``and`` eagerly rather than short-circuiting, so a leaf is
1147+
recorded whatever the other side is worth.
11201148
1121-
The string case is the opening quote's *matching close being the final
1122-
character*, not first/last-character equality: `'a' 'b'` passes the latter but
1123-
is two literals to the evaluator, which falls through to a path lookup.
1149+
A probe run can still raise on its own placeholder values, which is what
1150+
``_evaluator_rejects`` sorts out. The leaves seen before that point are real
1151+
-- the evaluator reached them -- so they are kept rather than discarded:
1152+
``inputs.tags | join(bogus)`` records ``bogus`` and only then trips over the
1153+
placeholder handed to ``join``.
11241154
"""
1125-
if text[:1] in ("'", '"') and text.find(text[0], 1) == len(text) - 1:
1126-
return True
1127-
return text.lower() in ("true", "false", "none", "null") or _looks_numeric(text)
1128-
1155+
leaves: list[str] = []
1156+
token = _leaf_sink.set(leaves)
1157+
try:
1158+
_evaluate_simple_expression(
1159+
text, {root: _ProbeNamespace() for root in _NAMESPACE_ROOTS}
1160+
)
1161+
except Exception: # noqa: BLE001 - probe values, reported by _evaluator_rejects
1162+
pass
1163+
finally:
1164+
_leaf_sink.reset(token)
1165+
return leaves
11291166

1130-
def _unresolvable_term(text: str) -> str | None:
1131-
"""The first operand in *text* the evaluator cannot resolve, or ``None``.
11321167

1133-
Walks operands the way ``_evaluate_simple_expression`` does -- filters, then
1134-
``or``/``and``/``not``, then comparisons -- and checks each leaf. A leaf must be
1135-
a literal or a dotted path rooted in ``_NAMESPACE_ROOTS``.
1168+
def _unresolvable_leaf(leaf: str) -> str | None:
1169+
"""Why the evaluator cannot resolve the name *leaf*, or ``None``.
11361170
1137-
Enumerating broken shapes is what made this take several rounds: each new gate
1138-
only knew the shapes named so far. ``inputs.a === inputs.b`` split cleanly on
1139-
``==`` and looked complete, while the evaluator read ``= inputs.b`` as a path
1140-
and resolved it to ``None``; ``bogus == 'x'`` passed for the same reason one
1141-
level up. Recursing to the leaves covers both without naming either.
1171+
Namespace knowledge only. Everything about where operands live now comes from
1172+
the evaluator itself, so nothing here restates the grammar.
11421173
"""
1143-
stripped = text.strip()
1144-
if not stripped:
1145-
return "an operand is empty"
1146-
1147-
if _find_top_level(stripped, "|") != -1:
1148-
segments = _split_top_level(stripped, "|")
1149-
reason = _unresolvable_term(segments[0])
1150-
if reason is not None:
1151-
return reason
1152-
# A filter argument is an ordinary operand to `_apply_filter`, which
1153-
# evaluates it with `_evaluate_simple_expression` like any other. Skipping
1154-
# it let `inputs.tags | join(bogus)` be offered as paste-ready: `bogus` is
1155-
# no namespace root, resolves to None, and the wrapped form then raises
1156-
# `join: expected a string separator, got NoneType`. Parse with the same
1157-
# pattern `_apply_filter` uses, so a form this does not recognize is left
1158-
# to the evaluator probe rather than guessed at here.
1159-
for segment in segments[1:]:
1160-
match = re.fullmatch(r"(\w+)\((.+)\)", segment.strip())
1161-
if match is None:
1162-
continue
1163-
reason = _unresolvable_term(match.group(2))
1164-
if reason is not None:
1165-
return reason
1166-
return None
1167-
1168-
for op in (" or ", " and "):
1169-
idx = _find_top_level(stripped, op)
1170-
if idx != -1:
1171-
return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
1172-
stripped[idx + len(op):]
1173-
)
1174-
1175-
if stripped.startswith("not "):
1176-
return _unresolvable_term(stripped[4:])
1177-
1178-
for op in _COMPARISON_OPERATORS:
1179-
idx = _find_top_level(stripped, op)
1180-
if idx != -1:
1181-
return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
1182-
stripped[idx + len(op):]
1183-
)
1184-
1185-
if _is_literal(stripped):
1186-
return None
1187-
1188-
# A list literal is a term the evaluator understands, and it recurses into the
1189-
# elements rather than resolving the brackets as a name. Not mirroring that
1190-
# denied the correction to `inputs.tag in ['x', 'y']` -- a condition wrapping
1191-
# repairs completely -- while reporting the list as an unresolvable name. The
1192-
# empty-segment skip matches `_evaluate_simple_expression`, which drops them so
1193-
# `[1, 2,]` is `[1, 2]` rather than `[1, 2, None]`.
1194-
if stripped.startswith("[") and stripped.endswith("]"):
1195-
inner = stripped[1:-1].strip()
1196-
if not inner:
1197-
return None
1198-
for element in _split_top_level_commas(inner):
1199-
if not element.strip():
1200-
continue
1201-
reason = _unresolvable_term(element)
1202-
if reason is not None:
1203-
return reason
1204-
return None
1205-
1206-
segments = _split_top_level(stripped, ".")
1207-
if not _PATH_SEGMENT.match(segments[0].strip()):
1208-
return f"{stripped!r} is not a name the evaluator can resolve"
1174+
segments = _split_top_level(leaf, ".")
1175+
if not _is_path_segment(segments[0].strip()):
1176+
return f"{leaf!r} is not a name the evaluator can resolve"
12091177
# `item` is the only root that is not always a mapping: `StepContext.item` is
12101178
# `Any` and a fan-out assigns the item value itself, so when that value is a
12111179
# list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Every
12121180
# other root comes back from `_build_namespace` as a mapping, and the index
12131181
# branch returns None for those however it is written -- so the index is
12141182
# stripped for `item` alone rather than for roots in general.
12151183
root = segments[0].strip()
1216-
indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root)
1184+
indexed_root = _INDEXED_SEGMENT.match(root)
12171185
if indexed_root is not None and indexed_root.group(1) == "item":
12181186
root = indexed_root.group(1)
12191187
if root not in _NAMESPACE_ROOTS:
@@ -1222,11 +1190,38 @@ def _unresolvable_term(text: str) -> str | None:
12221190
f"({', '.join(_NAMESPACE_ROOTS)})"
12231191
)
12241192
for segment in segments[1:]:
1225-
if not _PATH_SEGMENT.match(segment.strip()):
1193+
if not _is_path_segment(segment.strip()):
12261194
return f"{segment.strip()!r} is not a valid path segment"
12271195
return None
12281196

12291197

1198+
def _unresolvable_term(text: str) -> str | None:
1199+
"""The first name in *text* the evaluator cannot resolve, or ``None``.
1200+
1201+
Asks the evaluator which names it will look up, then applies the namespace
1202+
rules to those. The previous implementation derived the names itself by
1203+
re-walking the grammar -- filters, then ``or``/``and``/``not``, then
1204+
comparisons, then list literals -- and had to be kept in step with
1205+
``_evaluate_simple_expression`` by hand.
1206+
1207+
That is what made this take several rounds: each round fixed one shape the
1208+
walk disagreed about (``inputs.a === inputs.b``, ``bogus == 'x'``, list
1209+
elements, filter arguments, a newline before ``and``) and nothing stopped the
1210+
next one. Reading the leaves off the evaluator removes the class rather than
1211+
another instance of it: the two cannot disagree about where the operands are
1212+
when only one of them decides.
1213+
"""
1214+
stripped = text.strip()
1215+
if not stripped:
1216+
return "an operand is empty"
1217+
1218+
for leaf in _collect_leaves(stripped):
1219+
reason = _unresolvable_leaf(leaf)
1220+
if reason is not None:
1221+
return reason
1222+
return None
1223+
1224+
12301225
def _wrapping_would_not_repair(core: str) -> str | None:
12311226
"""Why wrapping *core* in ``{{ }}`` would not yield the expression intended.
12321227

0 commit comments

Comments
 (0)