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
11 changes: 11 additions & 0 deletions src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <str/list>``) 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")
Expand Down
25 changes: 25 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down