Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,40 @@ def _is_wrapped_in_parens(text: str) -> bool:
return False


def _collapse_whitespace(text: str) -> str:
"""Strip *text* and turn each run of whitespace outside a quoted string into
one space.
The operator scans below match word operators by their surrounding spaces
(``" or "``, ``" not in "``, ``expr.startswith("not ")``), so an operator
next to a newline or tab was never found. A condition wrapped across lines
in YAML keeps those newlines -- a ``|`` block scalar keeps every one, and a
``>`` folded scalar keeps the break before a more-indented continuation
line -- so ``{{ inputs.a or\\n inputs.b }}`` was resolved as one dot path,
came back ``None``, and read false with no error. Jinja2 treats any
whitespace between tokens alike; so does this, while quoted operands keep
their text exactly.
"""
out: list[str] = []
quote: str | None = None
pending_space = False
for ch in text.strip():
if quote is not None:
out.append(ch)
if ch == quote:
quote = None
elif ch.isspace():
pending_space = True
else:
if pending_space:
out.append(" ")
pending_space = False
if ch in ("'", '"'):
quote = ch
out.append(ch)
return "".join(out)


def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
Expand All @@ -589,7 +623,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| from_json``, ``| map('...')``
- String and numeric literals
"""
expr = expr.strip()
expr = _collapse_whitespace(expr)

# String literal — only when the WHOLE expression is one quoted string,
# i.e. the opening quote's matching close is the final character. Checking
Expand Down
58 changes: 58 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,64 @@ def test_parenthesised_grouping(self):
assert evaluate_expression("{{ 'a(b' }}", ctx) == "a(b"
assert evaluate_expression("{{ ('(') }}", ctx) == "("

@pytest.mark.parametrize(
("expression", "expected"),
[
("{{ inputs.b or\n inputs.a }}", True),
("{{ inputs.a and\n inputs.c }}", True),
("{{ inputs.b\nor inputs.a }}", True),
("{{ inputs.a\tand inputs.c }}", True),
("{{ not\n inputs.b }}", True),
("{{ 'x' in\n inputs.tags }}", True),
("{{ 'z' not\n in inputs.tags }}", True),
("{{ 'z' not in\n inputs.tags }}", True),
("{{ (inputs.b or\r\n inputs.a) and inputs.c }}", True),
],
)
def test_operators_separated_by_any_whitespace(self, expression, expected):
"""Word operators are found across newlines and tabs, as in Jinja2.
The operator scans match ``" or "`` and friends by their spaces, so an
operator next to a line break was never split on: the whole expression
was looked up as one dot path and came back ``None`` -- a false
condition with no error.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

ctx = StepContext(
inputs={"a": True, "b": False, "c": True, "mode": "fast", "tags": ["x", "y"]}
)
assert evaluate_expression(expression, ctx) is expected

def test_condition_wrapped_across_lines_in_yaml(self):
"""A long condition broken after its operator keeps the line break in
YAML (the continuation line is more indented, so ``>`` does not fold
it), and still has to evaluate as written."""
from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.base import StepContext

step = yaml.safe_load(
"condition: >-\n"
" {{ inputs.skip_review or\n"
" inputs.scope == 'docs' }}\n"
)
assert "\n" in step["condition"]

ctx = StepContext(inputs={"skip_review": False, "scope": "docs"})
assert evaluate_condition(step["condition"], ctx) is True

def test_whitespace_inside_quoted_operand_is_kept(self):
"""Collapsing whitespace between tokens leaves string literals alone."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

ctx = StepContext(inputs={"title": "two spaces", "text": "tab\there"})
assert evaluate_expression("{{ inputs.title == 'two spaces' }}", ctx) is True
assert evaluate_expression("{{ inputs.title == 'two spaces' }}", ctx) is False
assert evaluate_expression("{{ inputs.text\n == 'tab\there' }}", ctx) is True
assert evaluate_expression("{{ 'a or b' }}", ctx) == "a or b"

def test_list_indexing(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
Expand Down
23 changes: 19 additions & 4 deletions tests/unit/test_condition_expression_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,10 +560,6 @@ def test_incomplete_operand_covers_every_operator_the_evaluator_splits_on():
("inputs.a === inputs.b", "is not a name the evaluator can resolve"),
("bogus == 'x'", "is not one of the namespace roots"),
("inputs.payload | from_json()", "the evaluator rejects it"),
# `_find_top_level` matches " and " with literal spaces, so a newline before
# the keyword is not an operator: the wrapped form evaluates False where the
# same expression with a space evaluates True.
("inputs.x == 1\nand inputs.name == 'abc'", "is not a name the evaluator can resolve"),
]


Expand All @@ -578,6 +574,25 @@ def test_gates_inspect_every_position_not_just_the_first(step_cls, condition, ex
assert expected in errors[0]


@pytest.mark.parametrize("step_cls", STEP_CLASSES)
def test_a_condition_wrapped_across_lines_gets_a_paste_ready_correction(step_cls):
"""A line break before ``and`` no longer hides the operator.
The evaluator used to match " and " with literal spaces, so the wrapped form
of this condition read False where the one-line form read True, and the gate
had to withhold the correction. It now splits on any whitespace, so wrapping
repairs the condition and the correction is offered.
"""
condition = "inputs.x == 1\nand inputs.name == 'abc'"
config = {"id": "s1", "condition": condition, "then": [], "steps": []}
errors = [e for e in step_cls().validate(config) if "'condition'" in e]

assert len(errors) == 1
assert "Wrap the expression" in errors[0]
ctx = StepContext(inputs={"x": 1, "name": "abc"})
assert evaluate_condition("{{ " + condition + " }}", ctx) is True


@pytest.mark.parametrize(
"text,unbalanced",
[
Expand Down
Loading