diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 98005a790b..eeb8cba715 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -42,6 +42,14 @@ 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 before validate can + # report the malformed shape. Normalize the local to {} (self.data + # keeps the raw value for validate_workflow), mirroring 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 1c29ab56e6..bf5af5a3f1 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3612,6 +3612,30 @@ def test_from_string(self, sample_workflow_yaml): assert definition.id == "test-workflow" assert len(definition.inputs) == 2 + @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 (empty header) and let + # validate_workflow report the missing id/name, mirroring how the other + # raw fields are validated later. + 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). 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