diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index ca6047a57a..024ced55b5 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -70,6 +70,24 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Do-while step {config.get('id', '?')!r} is missing " f"'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # The engine re-evaluates 'condition' via evaluate_condition() after + # each iteration. That call first delegates to + # evaluate_expression() -- which returns a non-string unchanged -- + # and then coerces the result with bool(). So a list/dict/number + # condition silently resolves to its truthiness (e.g. + # condition: [1, 2] is always truthy, looping to max_iterations) + # with no error. Reject those at validation, mirroring the + # prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML and evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()). "true"/"false" + # and an expression like "{{ ... }}" stay valid too. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index b2ed880678..7189ff8150 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -61,6 +61,24 @@ def validate(self, config: dict[str, Any]) -> list[str]: errors.append( f"If step {config.get('id', '?')!r} is missing 'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # execute() feeds 'condition' to evaluate_condition(), which first + # delegates to evaluate_expression() -- that returns a non-string + # unchanged -- and then coerces the result with bool(). So a + # list/dict/number condition silently resolves to its truthiness + # (e.g. condition: [1, 2] is always True) with no error, branching + # wrongly on an authoring mistake. Reject those at validation, + # mirroring the prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML, evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()), and this step + # itself defaults ``condition`` to ``False``. "true"/"false" and an + # expression like "{{ ... }}" are strings, so they stay valid too. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) if "then" not in config: errors.append( f"If step {config.get('id', '?')!r} is missing 'then' field." diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index e2dbb19305..e80b93d7f2 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -79,6 +79,24 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"While step {config.get('id', '?')!r} is missing " f"'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # execute() feeds 'condition' to evaluate_condition(), which first + # delegates to evaluate_expression() -- that returns a non-string + # unchanged -- and then coerces the result with bool(). So a + # list/dict/number condition silently resolves to its truthiness + # (e.g. condition: [1, 2] is always truthy, spinning the loop to + # max_iterations) with no error. Reject those at validation, + # mirroring the prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML, evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()), and this step + # itself defaults ``condition`` to ``False``. "true"/"false" and an + # expression like "{{ ... }}" are strings, so they stay valid too. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 61c625a396..d94d530ae2 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2490,6 +2490,33 @@ def test_validate_missing_condition(self): errors = step.validate({"id": "test", "then": []}) assert any("missing 'condition'" in e for e in errors) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + # A list/dict/number condition is returned unchanged by + # evaluate_expression, and evaluate_condition then bool()-coerces it, so + # it silently resolves to its truthiness (e.g. [1, 2] is always True) + # instead of erroring on the authoring mistake. + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "condition": bad, "then": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize( + "good", + [ + "true", "false", "{{ inputs.flag }}", + True, False, # unquoted YAML bool: resolved exactly, and it is the + # default this step itself uses -- must stay valid + ], + ) + def test_validate_accepts_string_or_bool_condition(self, good): + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "condition": good, "then": []}) + assert not any("'condition' must be a" in e for e in errors), good + @pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5]) def test_execute_non_list_then_fails_loudly(self, bad_branch): """A non-list ``then`` must fail the step, not crash the run. @@ -2880,6 +2907,24 @@ def test_validate_missing_fields(self): assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": bad, "steps": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"]) + def test_validate_accepts_string_or_bool_condition(self, good): + # ``condition: false`` unquoted is idiomatic YAML and is this step's own + # default, so a literal bool must not be rejected. + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": good, "steps": []}) + assert not any("'condition' must be a" in e for e in errors), good + def test_validate_invalid_max_iterations(self): from specify_cli.workflows.steps.while_loop import WhileStep @@ -2994,6 +3039,24 @@ def test_validate_missing_fields(self): assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "condition": bad, "steps": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"]) + def test_validate_accepts_string_or_bool_condition(self, good): + # ``condition: false`` unquoted is idiomatic YAML; evaluate_condition + # resolves a literal bool exactly, so it must not be rejected. + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "condition": good, "steps": []}) + assert not any("'condition' must be a" in e for e in errors), good + def test_validate_steps_not_list(self): from specify_cli.workflows.steps.do_while import DoWhileStep