Skip to content

Commit 8720230

Browse files
lijiayongmr-c
andauthored
16957 check circular dep (#1543)
Co-authored-by: Michael R. Crusoe <[email protected]>
1 parent c422b27 commit 8720230

File tree

9 files changed

+222
-1
lines changed

9 files changed

+222
-1
lines changed

cwltool/checker.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,73 @@ def check_all_types(
437437
return validation
438438

439439

440+
def circular_dependency_checker(step_inputs: List[CWLObjectType]) -> None:
441+
"""Check if a workflow has circular dependency."""
442+
adjacency = get_dependency_tree(step_inputs)
443+
vertices = adjacency.keys()
444+
processed: List[str] = []
445+
cycles: List[List[str]] = []
446+
for vertex in vertices:
447+
if vertex not in processed:
448+
traversal_path = [vertex]
449+
processDFS(adjacency, traversal_path, processed, cycles)
450+
if cycles:
451+
exception_msg = "The following steps have circular dependency:\n"
452+
cyclestrs = [str(cycle) for cycle in cycles]
453+
exception_msg += "\n".join(cyclestrs)
454+
raise ValidationException(exception_msg)
455+
456+
457+
def get_dependency_tree(step_inputs: List[CWLObjectType]) -> Dict[str, List[str]]:
458+
"""Get the dependency tree in the form of adjacency list."""
459+
adjacency = {} # adjacency list of the dependency tree
460+
for step_input in step_inputs:
461+
if "source" in step_input:
462+
if isinstance(step_input["source"], list):
463+
vertices_in = [
464+
get_step_id(cast(str, src)) for src in step_input["source"]
465+
]
466+
else:
467+
vertices_in = [get_step_id(cast(str, step_input["source"]))]
468+
vertex_out = get_step_id(cast(str, step_input["id"]))
469+
for vertex_in in vertices_in:
470+
if vertex_in not in adjacency:
471+
adjacency[vertex_in] = [vertex_out]
472+
elif vertex_out not in adjacency[vertex_in]:
473+
adjacency[vertex_in].append(vertex_out)
474+
if vertex_out not in adjacency:
475+
adjacency[vertex_out] = []
476+
return adjacency
477+
478+
479+
def processDFS(
480+
adjacency: Dict[str, List[str]],
481+
traversal_path: List[str],
482+
processed: List[str],
483+
cycles: List[List[str]],
484+
) -> None:
485+
"""Perform depth first search."""
486+
tip = traversal_path[-1]
487+
for vertex in adjacency[tip]:
488+
if vertex in traversal_path:
489+
i = traversal_path.index(vertex)
490+
cycles.append(traversal_path[i:])
491+
elif vertex not in processed:
492+
traversal_path.append(vertex)
493+
processDFS(adjacency, traversal_path, processed, cycles)
494+
processed.append(tip)
495+
traversal_path.pop()
496+
497+
498+
def get_step_id(field_id: str) -> str:
499+
"""Extract step id from either input or output fields."""
500+
if "/" in field_id.split("#")[1]:
501+
step_id = "/".join(field_id.split("/")[:-1])
502+
else:
503+
step_id = field_id.split("#")[0]
504+
return step_id
505+
506+
440507
def is_conditional_step(param_to_step: Dict[str, CWLObjectType], parm_id: str) -> bool:
441508
source_step = param_to_step.get(parm_id)
442509
if source_step is not None:

cwltool/workflow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from schema_salad.sourceline import SourceLine, indent
2121

2222
from . import command_line_tool, context, procgenerator
23-
from .checker import static_checker
23+
from .checker import static_checker, circular_dependency_checker
2424
from .context import LoadingContext, RuntimeContext, getdefault
2525
from .errors import WorkflowException
2626
from .load_tool import load_tool
@@ -139,6 +139,7 @@ def __init__(
139139
step_outputs,
140140
param_to_step,
141141
)
142+
circular_dependency_checker(step_inputs)
142143

143144
def make_workflow_step(
144145
self,

tests/checker_wf/cat-a.cwl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.1
3+
class: CommandLineTool
4+
inputs:
5+
intxt:
6+
type: File[]
7+
inputBinding: {}
8+
outputs:
9+
cattxt:
10+
type: stdout
11+
baseCommand: [cat, -A]
12+
stdout: cat.txt

tests/checker_wf/circ-dep-wf.cwl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.1
3+
class: Workflow
4+
requirements:
5+
MultipleInputFeatureRequirement: {}
6+
7+
inputs:
8+
txt:
9+
type: File
10+
default:
11+
class: File
12+
location: test.txt
13+
14+
outputs:
15+
wctxt:
16+
type: File
17+
outputSource: wc/wctxt
18+
19+
steps:
20+
cat-a:
21+
run: cat-a.cwl
22+
in:
23+
intxt:
24+
source: [txt, wc/wctxt]
25+
linkMerge: merge_flattened
26+
out: [cattxt]
27+
ls:
28+
run: ls.cwl
29+
in:
30+
intxt: cat-a/cattxt
31+
out: [lstxt]
32+
wc:
33+
run: wc.cwl
34+
in:
35+
intxt: ls/lstxt
36+
out: [wctxt]

tests/checker_wf/circ-dep-wf2.cwl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.1
3+
class: Workflow
4+
requirements:
5+
MultipleInputFeatureRequirement: {}
6+
7+
inputs:
8+
txt:
9+
type: File
10+
default:
11+
class: File
12+
location: test.txt
13+
14+
outputs:
15+
wctxt:
16+
type: File
17+
outputSource: wc/wctxt
18+
19+
steps:
20+
cat-a:
21+
run: cat-a.cwl
22+
in:
23+
intxt:
24+
source: [txt, txt]
25+
linkMerge: merge_flattened
26+
out: [cattxt]
27+
ls:
28+
run: ls.cwl
29+
in:
30+
intxt: ls/lstxt
31+
out: [lstxt]
32+
wc:
33+
run: wc.cwl
34+
in:
35+
intxt: ls/lstxt
36+
out: [wctxt]

tests/checker_wf/ls.cwl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.1
3+
class: CommandLineTool
4+
inputs:
5+
intxt:
6+
type: File
7+
inputBinding: {}
8+
outputs:
9+
lstxt:
10+
type: stdout
11+
baseCommand: ls
12+
stdout: ls.txt

tests/checker_wf/test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
a

tests/checker_wf/wc.cwl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env cwl-runner
2+
cwlVersion: v1.1
3+
class: CommandLineTool
4+
inputs:
5+
intxt:
6+
type: File
7+
inputBinding: {}
8+
outputs:
9+
wctxt:
10+
type: stdout
11+
baseCommand: wc
12+
stdout: wc.txt

tests/test_examples.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,7 @@ def test_format_expr_error() -> None:
921921
def test_static_checker() -> None:
922922
# check that the static checker raises exception when a source type
923923
# mismatches its sink type.
924+
"""Confirm that static type checker raises expected exception."""
924925
factory = cwltool.factory.Factory()
925926

926927
with pytest.raises(ValidationException):
@@ -933,6 +934,49 @@ def test_static_checker() -> None:
933934
factory.make(get_data("tests/checker_wf/broken-wf3.cwl"))
934935

935936

937+
def test_circular_dependency_checker() -> None:
938+
# check that the circular dependency checker raises exception when there is
939+
# circular dependency in the workflow.
940+
"""Confirm that circular dependency checker raises expected exception."""
941+
factory = cwltool.factory.Factory()
942+
943+
with pytest.raises(
944+
ValidationException,
945+
match=r".*The\s*following\s*steps\s*have\s*circular\s*dependency:\s*.*",
946+
):
947+
factory.make(get_data("tests/checker_wf/circ-dep-wf.cwl"))
948+
949+
with pytest.raises(
950+
ValidationException,
951+
match=r".*#cat-a.*",
952+
):
953+
factory.make(get_data("tests/checker_wf/circ-dep-wf.cwl"))
954+
955+
with pytest.raises(
956+
ValidationException,
957+
match=r".*#ls.*",
958+
):
959+
factory.make(get_data("tests/checker_wf/circ-dep-wf.cwl"))
960+
961+
with pytest.raises(
962+
ValidationException,
963+
match=r".*#wc.*",
964+
):
965+
factory.make(get_data("tests/checker_wf/circ-dep-wf.cwl"))
966+
967+
with pytest.raises(
968+
ValidationException,
969+
match=r".*The\s*following\s*steps\s*have\s*circular\s*dependency:\s*.*",
970+
):
971+
factory.make(get_data("tests/checker_wf/circ-dep-wf2.cwl"))
972+
973+
with pytest.raises(
974+
ValidationException,
975+
match=r".*#ls.*",
976+
):
977+
factory.make(get_data("tests/checker_wf/circ-dep-wf2.cwl"))
978+
979+
936980
def test_var_spool_cwl_checker1() -> None:
937981
"""Confirm that references to /var/spool/cwl are caught."""
938982
stream = StringIO()

0 commit comments

Comments
 (0)