diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 03afaee74e..13fd633338 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -42,6 +42,17 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non self.source_path = source_path workflow = data.get("workflow", {}) + # A present-but-non-mapping ``workflow:`` block (bare ``workflow:`` -> + # None, or ``workflow: ``) would crash the following + # ``workflow.get(...)`` calls with AttributeError, so construction fails + # before any validation can run. Normalize the local to {} instead: the + # header fields fall back to their defaults and ``validate_workflow`` + # (which reads those parsed attributes) reports the missing + # ``workflow.id``/``workflow.name``. ``self.data`` is deliberately left + # holding the raw value, since it is what gets written back out when a + # definition is serialized. Mirrors the default_options guard below. + if not isinstance(workflow, dict): + workflow = {} self.id: str = workflow.get("id", "") self.name: str = workflow.get("name", "") self.version: str = workflow.get("version", "0.0.0") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 54cea4d770..61575d71df 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3629,6 +3629,31 @@ def test_resolve_inputs_tolerates_non_mapping_inputs(self, block): resolved = WorkflowEngine()._resolve_inputs(definition, {}) # must not raise assert resolved == {} + @pytest.mark.parametrize( + "block", ["workflow:\nsteps: []\n", "workflow: hi\nsteps: []\n", "workflow: [a]\nsteps: []\n"] + ) + def test_non_mapping_workflow_block_parses_then_validates(self, block): + # A present-but-non-mapping `workflow:` block must not crash construction + # with AttributeError; it should parse to an empty header so + # validate_workflow reports the missing id/name (it reads the parsed + # attributes, not the raw block). + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(block) # must not raise + assert definition.id == "" + errors = validate_workflow(definition) + assert any("workflow.id" in e for e in errors) + # The RAW malformed value is preserved on .data (the guard only + # normalizes the local var, not self.data) — .data is what gets written + # back out when a definition is serialized. Assert it was NOT replaced + # with {} by comparing against the original parse and confirming it is + # still a non-mapping. + import yaml + + raw_workflow = yaml.safe_load(block).get("workflow") + assert definition.data["workflow"] == raw_workflow + assert not isinstance(definition.data["workflow"], dict) + def test_from_string_invalid(self): from specify_cli.workflows.engine import WorkflowDefinition