Skip to content
Open
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
6 changes: 4 additions & 2 deletions statemachine/spec_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,10 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
if expr.strip() == "":
raise SyntaxError("Empty expression")

# Optimization trying to avoid parsing the expression if not needed
if "!" not in expr and " " not in expr and "In(" not in expr:
# Optimization trying to avoid parsing the expression if not needed. Skip it
# when any classical operator is present, not just "!" -- an unspaced "^"/"v"
# (e.g. "a^b") would otherwise be swallowed into a single variable name.
if not pattern.search(expr) and " " not in expr and "In(" not in expr:
Comment on lines +318 to +321

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gating on pattern fixes the reported cases, but it introduces two regressions. I verified both against develop (both fail loudly at class-definition time):

  1. Unspaced != stops working. pattern deliberately skips != via the \!(?!=) lookahead, so cond="steps!=2" no longer matches, takes the fast-path, and is resolved as a variable literally named steps!=2:

    InvalidDefinition: Did not found name 'steps!=2' from model or statemachine
    

    On develop, any ! character skipped the fast-path, so this parsed as a comparison and worked.

  2. A guard literally named v stops working. \bv\b matches a bare v, so cond="v" (a boolean attribute named v) now goes through replace_operators, becomes " or ", and fails with:

    InvalidDefinition: Failed to parse boolean expression 'v'
    

The invariant the fast-path wants is "this whole string is a single variable name", and Python can check that directly:

Suggested change
# Optimization trying to avoid parsing the expression if not needed. Skip it
# when any classical operator is present, not just "!" -- an unspaced "^"/"v"
# (e.g. "a^b") would otherwise be swallowed into a single variable name.
if not pattern.search(expr) and " " not in expr and "In(" not in expr:
# Optimization: a lone identifier can only be a variable name, so there is
# nothing to parse. Anything else (operators, comparisons, spaces, calls)
# goes through the parser.
if expr.isidentifier():

This keeps your fix for a^b / (a)v(b), keeps unspaced != and bare v working, makes the " " and "In(" checks redundant, and as a bonus also fixes the pre-existing sibling bug where unspaced comparisons like x==1 / x>=1 were swallowed as variable names too.

One side effect to be aware of: inputs that were already invalid, like cond="user.age", now fail with ValueError: Unsupported expression structure: Attribute instead of InvalidDefinition: Did not found name .... If we want to keep the friendlier error, dispatcher.build can catch ValueError alongside SyntaxError.

return variable_hook(expr)
expr = replace_operators(expr)
tree = ast.parse(expr, mode="eval")
Expand Down
12 changes: 12 additions & 0 deletions tests/test_spec_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ def test_classical_operators_name():
) # name reflects expression structure


def test_classical_operators_without_spaces():
# "^" and "v" are operators even without surrounding whitespace, like "!",
# so they must not be swallowed into a single variable name.
for unspaced, spaced in [
("frodo_has_ring^sam_is_loyal", "frodo_has_ring ^ sam_is_loyal"),
("(frodo_has_ring)v(sauron_alive)", "(frodo_has_ring) v (sauron_alive)"),
]:
got = parse_boolean_expr(unspaced, variable_hook, operator_mapping)()
want = parse_boolean_expr(spaced, variable_hook, operator_mapping)()
assert got is want, unspaced

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test, thanks for including it. Since CI stayed green despite the regressions mentioned in spec_parser.py, could you extend the coverage here to pin the behaviors that must keep working? These two cases would have caught them:

def test_unspaced_not_equal_is_a_comparison():
    # "!" is an operator, but "!=" is not a negation: it must keep parsing
    # as a comparison even without surrounding whitespace.
    got = parse_boolean_expr("frodo_age!=51", variable_hook, operator_mapping)()
    want = parse_boolean_expr("frodo_age != 51", variable_hook, operator_mapping)()
    assert got is want is True


def test_bare_v_is_a_variable_name():
    # "v" is only an operator between operands; a lone "v" is a plain variable.
    expr = parse_boolean_expr("v", variable_hook, operator_mapping)
    assert expr.__name__ == "v"

Note the !=51 (not !=50): a misparse resolves the whole string as an unknown variable, which this variable_hook defaults to False, so a !=50 case would pass for the wrong reason.



def test_empty_expression():
expr = ""
with pytest.raises(SyntaxError):
Expand Down
Loading