Repository: common-workflow-language/cwltool

Issue:
Feature request: Add option to execute only a single workflow step
I would like to execute just a single step of a workflow.

For example, call cwltool with `--target wf/step!` and have only that step executed, not the whole workflow starting from that step.

Update: I think it would be even better to generalize the interface, so that one could specify a range of steps to run. So, for example `--target wf/stepB-stepB` would run just step B and `--target wf/stepB-stepD` would run steps B, C, and D. 



Discussion:
So if I'm going by the output of --print-subgraph, then it looks like `--target B` already works as expected - it only runs step B. So it looks like it's just the documentation for the `--target` option is wrong in the README https://github.com/common-workflow-language/cwltool#running-only-part-of-a-workflow
If you can confirm that `--target` works the way I expect then I will close this issue. Thanks!
What do you want to happen if there are downstream steps?  I the current behavior is "the target step and everything downstream from the target step".  So if there are no downstream steps, it will only run step B.

You can also set an output parameter as a target, and then it will only run the steps that contribute to the output parameter.  If there are no upstream steps from step B, it'll only run step B.

If you want to be able to arbitrarily clip some portion of the subgraph, that would be a great feature if you would like to work on it.
I ran `cwltool --target B --print-subgraph my.cwl` on a workflow that has 4 steps. The output of --print-subgraph only shows step B.
To be more specific, I ran `cwltool --target step2 --print-subgraph tests/subgraph/count-lines1-wf.cwl` and got 

```
{
    "class": "Workflow",
    "cwlVersion": "v1.0",
    "id": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl",
    "inputs": [
        {
            "id": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step1_output",
            "type": "File"
        }
    ],
    "outputs": [
        {
            "id": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#count_output",
            "outputSource": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step2/output",
            "type": "int"
        }
    ],
    "steps": [
        {
            "id": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step2",
            "in": [
                {
                    "id": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step2/file1",
                    "source": "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step1_output"
                }
            ],
            "out": [
                "file:///Users/jdidion/projects/cwltool/tests/subgraph/count-lines1-wf.cwl#step2/output"
            ],
            "run": "file:///Users/jdidion/projects/cwltool/tests/subgraph/parseInt-tool.cwl"
        }
    ]
}
```
Are those steps connected A->B->C->D ?

The documented intention was that it would run B->C->D but it's been a long time since I worked on this feature.

The _intention_ was to mimic something like `make` where you could select a specific target to build out a file that combined several potentially independent workflows.
Ok, yea, when it says "it will run the workflow starting from that step." that means the downstream dependency graph not the linear list of steps in the file.  In that example, there are no downstream steps from step2.


You're right - it's just that the second step was not connected to anything. When I try with a step that is connected downstream then all the steps are included.
1. Refactor out DAG generation from https://github.com/common-workflow-language/cwltool/blob/main/cwltool/subgraph.py#L47
2. Add command line option for `--extract-step` or similar

Resolving patch:
diff --git a/cwltool/argparser.py b/cwltool/argparser.py
--- a/cwltool/argparser.py
+++ b/cwltool/argparser.py
@@ -64,8 +64,8 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="preserve_entire_environment",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    containergroup = parser.add_mutually_exclusive_group()
+    containergroup.add_argument(
         "--rm-container",
         action="store_true",
         default=True,
@@ -73,7 +73,7 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="rm_container",
     )
 
-    exgroup.add_argument(
+    containergroup.add_argument(
         "--leave-container",
         action="store_false",
         default=True,
@@ -123,8 +123,8 @@ def arg_parser() -> argparse.ArgumentParser:
         default=DEFAULT_TMP_PREFIX,
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    intgroup = parser.add_mutually_exclusive_group()
+    intgroup.add_argument(
         "--tmp-outdir-prefix",
         type=str,
         help="Path prefix for intermediate output directories. Defaults to the "
@@ -132,7 +132,7 @@ def arg_parser() -> argparse.ArgumentParser:
         default="",
     )
 
-    exgroup.add_argument(
+    intgroup.add_argument(
         "--cachedir",
         type=str,
         default="",
@@ -141,8 +141,8 @@ def arg_parser() -> argparse.ArgumentParser:
         "troubleshooting of CWL documents.",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    tmpgroup = parser.add_mutually_exclusive_group()
+    tmpgroup.add_argument(
         "--rm-tmpdir",
         action="store_true",
         default=True,
@@ -150,7 +150,7 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="rm_tmpdir",
     )
 
-    exgroup.add_argument(
+    tmpgroup.add_argument(
         "--leave-tmpdir",
         action="store_false",
         default=True,
@@ -158,8 +158,8 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="rm_tmpdir",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    outgroup = parser.add_mutually_exclusive_group()
+    outgroup.add_argument(
         "--move-outputs",
         action="store_const",
         const="move",
@@ -169,7 +169,7 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="move_outputs",
     )
 
-    exgroup.add_argument(
+    outgroup.add_argument(
         "--leave-outputs",
         action="store_const",
         const="leave",
@@ -178,7 +178,7 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="move_outputs",
     )
 
-    exgroup.add_argument(
+    outgroup.add_argument(
         "--copy-outputs",
         action="store_const",
         const="copy",
@@ -188,8 +188,8 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="move_outputs",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    pullgroup = parser.add_mutually_exclusive_group()
+    pullgroup.add_argument(
         "--enable-pull",
         default=True,
         action="store_true",
@@ -197,7 +197,7 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="pull_image",
     )
 
-    exgroup.add_argument(
+    pullgroup.add_argument(
         "--disable-pull",
         default=True,
         action="store_false",
@@ -280,65 +280,68 @@ def arg_parser() -> argparse.ArgumentParser:
         type=str,
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    printgroup = parser.add_mutually_exclusive_group()
+    printgroup.add_argument(
         "--print-rdf",
         action="store_true",
         help="Print corresponding RDF graph for workflow and exit",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-dot",
         action="store_true",
         help="Print workflow visualization in graphviz format and exit",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-pre",
         action="store_true",
         help="Print CWL document after preprocessing.",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-deps", action="store_true", help="Print CWL document dependencies."
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-input-deps",
         action="store_true",
         help="Print input object document dependencies.",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--pack",
         action="store_true",
         help="Combine components into single document and print.",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--version", action="store_true", help="Print version and exit"
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--validate", action="store_true", help="Validate CWL document only."
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-supported-versions",
         action="store_true",
         help="Print supported CWL specs.",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-subgraph",
         action="store_true",
         help="Print workflow subgraph that will execute. Can combined with "
-        "--target.",
+        "--target or --single-step",
     )
-    exgroup.add_argument(
+    printgroup.add_argument(
         "--print-targets", action="store_true", help="Print targets (output parameters)"
     )
+    printgroup.add_argument(
+        "--make-template", action="store_true", help="Generate a template input object"
+    )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    strictgroup = parser.add_mutually_exclusive_group()
+    strictgroup.add_argument(
         "--strict",
         action="store_true",
         help="Strict validation (unrecognized or out of place fields are error)",
         default=True,
         dest="strict",
     )
-    exgroup.add_argument(
+    strictgroup.add_argument(
         "--non-strict",
         action="store_false",
         help="Lenient validation (ignore unrecognized fields)",
@@ -354,15 +357,15 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="skip_schemas",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    doccachegroup = parser.add_mutually_exclusive_group()
+    doccachegroup.add_argument(
         "--no-doc-cache",
         action="store_false",
         help="Disable disk cache for documents loaded over HTTP",
         default=True,
         dest="doc_cache",
     )
-    exgroup.add_argument(
+    doccachegroup.add_argument(
         "--doc-cache",
         action="store_true",
         help="Enable disk cache for documents loaded over HTTP",
@@ -370,12 +373,14 @@ def arg_parser() -> argparse.ArgumentParser:
         dest="doc_cache",
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument("--verbose", action="store_true", help="Default logging")
-    exgroup.add_argument(
+    volumegroup = parser.add_mutually_exclusive_group()
+    volumegroup.add_argument("--verbose", action="store_true", help="Default logging")
+    volumegroup.add_argument(
         "--quiet", action="store_true", help="Only print warnings and errors."
     )
-    exgroup.add_argument("--debug", action="store_true", help="Print even more logging")
+    volumegroup.add_argument(
+        "--debug", action="store_true", help="Print even more logging"
+    )
 
     parser.add_argument(
         "--strict-memory-limit",
@@ -506,14 +511,14 @@ def arg_parser() -> argparse.ArgumentParser:
         default=False,
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    colorgroup = parser.add_mutually_exclusive_group()
+    colorgroup.add_argument(
         "--enable-color",
         action="store_true",
         help="Enable logging color (default enabled)",
         default=not onWindows(),
     )
-    exgroup.add_argument(
+    colorgroup.add_argument(
         "--disable-color",
         action="store_false",
         dest="enable_color",
@@ -545,15 +550,15 @@ def arg_parser() -> argparse.ArgumentParser:
         help=argparse.SUPPRESS,
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    reggroup = parser.add_mutually_exclusive_group()
+    reggroup.add_argument(
         "--enable-ga4gh-tool-registry",
         action="store_true",
         help="Enable tool resolution using GA4GH tool registry API",
         dest="enable_ga4gh_tool_registry",
         default=True,
     )
-    exgroup.add_argument(
+    reggroup.add_argument(
         "--disable-ga4gh-tool-registry",
         action="store_false",
         help="Disable tool resolution using GA4GH tool registry API",
@@ -579,15 +584,15 @@ def arg_parser() -> argparse.ArgumentParser:
         choices=("stop", "continue"),
     )
 
-    exgroup = parser.add_mutually_exclusive_group()
-    exgroup.add_argument(
+    checkgroup = parser.add_mutually_exclusive_group()
+    checkgroup.add_argument(
         "--compute-checksum",
         action="store_true",
         default=True,
         help="Compute checksum of contents while collecting outputs",
         dest="compute_checksum",
     )
-    exgroup.add_argument(
+    checkgroup.add_argument(
         "--no-compute-checksum",
         action="store_false",
         help="Do not compute checksum of contents while collecting outputs",
@@ -602,9 +607,6 @@ def arg_parser() -> argparse.ArgumentParser:
         "spaces and hash characters.",
         dest="relax_path_checks",
     )
-    exgroup.add_argument(
-        "--make-template", action="store_true", help="Generate a template input object"
-    )
 
     parser.add_argument(
         "--force-docker-pull",
@@ -628,13 +630,21 @@ def arg_parser() -> argparse.ArgumentParser:
         help="Read process requirement overrides from file.",
     )
 
-    parser.add_argument(
+    subgroup = parser.add_mutually_exclusive_group()
+    subgroup.add_argument(
         "--target",
         "-t",
         action="append",
         help="Only execute steps that contribute to listed targets (can be "
         "provided more than once).",
     )
+    subgroup.add_argument(
+        "--single-step",
+        type=str,
+        default=None,
+        help="Only executes a single step in a workflow. The input object must "
+        "match that step's inputs. Can be combined with --print-subgraph.",
+    )
 
     parser.add_argument(
         "--mpi-config-file",
diff --git a/cwltool/main.py b/cwltool/main.py
--- a/cwltool/main.py
+++ b/cwltool/main.py
@@ -80,7 +80,7 @@
     get_container_from_software_requirements,
 )
 from .stdfsaccess import StdFsAccess
-from .subgraph import get_subgraph
+from .subgraph import get_step, get_subgraph
 from .update import ALLUPDATES, UPDATES
 from .utils import (
     DEFAULT_TMP_PREFIX,
@@ -114,11 +114,15 @@ def _terminate_processes() -> None:
     # we're executing, so it's not safe to use a for loop here.
     while processes_to_kill:
         process = processes_to_kill.popleft()
-        cidfile = [str(arg).split("=")[1] for arg in process.args if "--cidfile" in str(arg)]
+        cidfile = [
+            str(arg).split("=")[1] for arg in process.args if "--cidfile" in str(arg)
+        ]
         if cidfile:
             try:
                 with open(cidfile[0], "r") as inp_stream:
-                    p = subprocess.Popen(["docker", "kill", inp_stream.read()], shell=False)  # nosec
+                    p = subprocess.Popen(  # nosec
+                        ["docker", "kill", inp_stream.read()], shell=False  # nosec
+                    )
                     try:
                         p.wait(timeout=10)
                     except subprocess.TimeoutExpired:
@@ -764,7 +768,7 @@ def choose_target(
     tool: Process,
     loadingContext: LoadingContext,
 ) -> Optional[Process]:
-    """Walk the given Workflow and find the process that matches args.target."""
+    """Walk the Workflow, extract the subset matches all the args.targets."""
     if loadingContext.loader is None:
         raise Exception("loadingContext.loader cannot be None")
 
@@ -794,6 +798,38 @@ def choose_target(
     return tool
 
 
+def choose_step(
+    args: argparse.Namespace,
+    tool: Process,
+    loadingContext: LoadingContext,
+) -> Optional[Process]:
+    """Walk the given Workflow and extract just args.single_step."""
+    if loadingContext.loader is None:
+        raise Exception("loadingContext.loader cannot be None")
+
+    if isinstance(tool, Workflow):
+        url = urllib.parse.urlparse(tool.tool["id"])
+        if url.fragment:
+            extracted = get_step(tool, tool.tool["id"] + "/" + args.singe_step)
+        else:
+            extracted = get_step(
+                tool,
+                loadingContext.loader.fetcher.urljoin(
+                    tool.tool["id"], "#" + args.single_step
+                ),
+            )
+    else:
+        _logger.error("Can only use --single-step on Workflows")
+        return None
+    if isinstance(loadingContext.loader.idx, MutableMapping):
+        loadingContext.loader.idx[extracted["id"]] = extracted
+        tool = make_tool(extracted["id"], loadingContext)
+    else:
+        raise Exception("Missing loadingContext.loader.idx!")
+
+    return tool
+
+
 def check_working_directories(
     runtimeContext: RuntimeContext,
 ) -> Optional[int]:
@@ -1033,6 +1069,13 @@ def main(
                 else:
                     tool = ctool
 
+            elif args.single_step:
+                ctool = choose_step(args, tool, loadingContext)
+                if ctool is None:
+                    return 1
+                else:
+                    tool = ctool
+
             if args.print_subgraph:
                 if "name" in tool.tool:
                     del tool.tool["name"]
diff --git a/cwltool/subgraph.py b/cwltool/subgraph.py
--- a/cwltool/subgraph.py
+++ b/cwltool/subgraph.py
@@ -1,11 +1,20 @@
 import urllib
 from collections import namedtuple
-from typing import Dict, MutableMapping, MutableSequence, Optional, Set, Tuple, cast
+from typing import (
+    Dict,
+    List,
+    MutableMapping,
+    MutableSequence,
+    Optional,
+    Set,
+    Tuple,
+    cast,
+)
 
 from ruamel.yaml.comments import CommentedMap
 
 from .utils import CWLObjectType, aslist
-from .workflow import Workflow
+from .workflow import Workflow, WorkflowStep
 
 Node = namedtuple("Node", ("up", "down", "type"))
 UP = "up"
@@ -44,11 +53,18 @@ def declare_node(nodes: Dict[str, Node], nodeid: str, tp: Optional[str]) -> Node
     return nodes[nodeid]
 
 
+def find_step(steps: List[WorkflowStep], stepid: str) -> Optional[CWLObjectType]:
+    for st in steps:
+        if st.tool["id"] == stepid:
+            return st.tool
+    return None
+
+
 def get_subgraph(roots: MutableSequence[str], tool: Workflow) -> CommentedMap:
     if tool.tool["class"] != "Workflow":
         raise Exception("Can only extract subgraph from workflow")
 
-    nodes = {}  # type: Dict[str, Node]
+    nodes: Dict[str, Node] = {}
 
     for inp in tool.tool["inputs"]:
         declare_node(nodes, inp["id"], INPUT)
@@ -81,22 +97,16 @@ def get_subgraph(roots: MutableSequence[str], tool: Workflow) -> CommentedMap:
             nodes[out].up.append(st["id"])
 
     # Find all the downstream nodes from the starting points
-    visited_down = set()  # type: Set[str]
+    visited_down: Set[str] = set()
     for r in roots:
         if nodes[r].type == OUTPUT:
             subgraph_visit(r, nodes, visited_down, UP)
         else:
             subgraph_visit(r, nodes, visited_down, DOWN)
 
-    def find_step(stepid: str) -> Optional[CWLObjectType]:
-        for st in tool.steps:
-            if st.tool["id"] == stepid:
-                return st.tool
-        return None
-
     # Now make sure all the nodes are connected to upstream inputs
-    visited = set()  # type: Set[str]
-    rewire = {}  # type: Dict[str, Tuple[str, str]]
+    visited: Set[str] = set()
+    rewire: Dict[str, Tuple[str, CWLObjectType]] = {}
     for v in visited_down:
         visited.add(v)
         if nodes[v].type in (STEP, OUTPUT):
@@ -108,15 +118,17 @@ def find_step(stepid: str) -> Optional[CWLObjectType]:
                 else:
                     # rewire
                     df = urllib.parse.urldefrag(u)
-                    rn = df[0] + "#" + df[1].replace("/", "_")
+                    rn = str(df[0] + "#" + df[1].replace("/", "_"))
                     if nodes[v].type == STEP:
-                        wfstep = find_step(v)
+                        wfstep = find_step(tool.steps, v)
                         if wfstep is not None:
                             for inp in cast(
                                 MutableSequence[CWLObjectType], wfstep["inputs"]
                             ):
-                                if u in inp["source"]:
-                                    rewire[u] = (rn, inp["type"])
+                                if "source" in inp and u in cast(
+                                    CWLObjectType, inp["source"]
+                                ):
+                                    rewire[u] = (rn, cast(CWLObjectType, inp["type"]))
                                     break
                         else:
                             raise Exception("Could not find step %s" % v)
@@ -147,3 +159,35 @@ def find_step(stepid: str) -> Optional[CWLObjectType]:
         extracted["inputs"].append({"id": rv[0], "type": rv[1]})
 
     return extracted
+
+
+def get_step(tool: Workflow, step_id: str) -> CommentedMap:
+
+    extracted = CommentedMap()
+
+    step = find_step(tool.steps, step_id)
+    if step is None:
+        raise Exception(f"Step {step_id} was not found")
+
+    extracted["steps"] = [step]
+    extracted["inputs"] = []
+    extracted["outputs"] = []
+
+    for inport in cast(List[CWLObjectType], step["in"]):
+        name = cast(str, inport["id"]).split("#")[-1].split("/")[-1]
+        extracted["inputs"].append({"id": name, "type": "Any"})
+        inport["source"] = name
+        if "linkMerge" in inport:
+            del inport["linkMerge"]
+
+    for outport in cast(List[str], step["out"]):
+        name = outport.split("#")[-1].split("/")[-1]
+        extracted["outputs"].append(
+            {"id": name, "type": "Any", "outputSource": f"{step_id}/{name}"}
+        )
+
+    for f in tool.tool:
+        if f not in ("steps", "inputs", "outputs"):
+            extracted[f] = tool.tool[f]
+
+    return extracted


Test patch:
diff --git a/tests/subgraph/single_step1.json b/tests/subgraph/single_step1.json
new file mode 100644
--- /dev/null
+++ b/tests/subgraph/single_step1.json
@@ -0,0 +1,23 @@
+{"class": "Workflow",
+ "cwlVersion": "v1.0",
+ "id": "count-lines1-wf.cwl",
+ "inputs": [{"id": "file1", "type": "Any"}],
+ "outputs": [{"id": "output",
+              "outputSource": "count-lines1-wf.cwl#step1/output",
+              "type": "Any"}],
+ "steps": [{"id": "count-lines1-wf.cwl#step1",
+            "in": [{"id": "count-lines1-wf.cwl#step1/file1",
+                    "source": "file1"}],
+            "inputs": [{"_tool_entry": {"id": "wc-tool.cwl#file1",
+                                        "type": "File"},
+                        "id": "count-lines1-wf.cwl#step1/file1",
+                        "source": "count-lines1-wf.cwl#file1",
+                        "type": "File"}],
+            "out": ["count-lines1-wf.cwl#step1/output"],
+            "outputs": [{"_tool_entry": {"id": "wc-tool.cwl#output",
+                                         "outputBinding": {"glob": "output"},
+                                         "type": "File"},
+                         "id": "count-lines1-wf.cwl#step1/output",
+                         "outputBinding": {"glob": "output"},
+                         "type": "File"}],
+            "run": "wc-tool.cwl"}]}
diff --git a/tests/subgraph/single_step2.json b/tests/subgraph/single_step2.json
new file mode 100644
--- /dev/null
+++ b/tests/subgraph/single_step2.json
@@ -0,0 +1,23 @@
+{"class": "Workflow",
+ "cwlVersion": "v1.0",
+ "id": "count-lines1-wf.cwl",
+ "inputs": [{"id": "file1", "type": "Any"}],
+ "outputs": [{"id": "output",
+              "outputSource": "count-lines1-wf.cwl#step2/output",
+              "type": "Any"}],
+ "steps": [{"id": "count-lines1-wf.cwl#step2",
+            "in": [{"id": "count-lines1-wf.cwl#step2/file1",
+                    "source": "file1"}],
+            "inputs": [{"_tool_entry": {"id": "parseInt-tool.cwl#file1",
+                                        "inputBinding": {"loadContents": true},
+                                        "type": "File"},
+                        "id": "count-lines1-wf.cwl#step2/file1",
+                        "inputBinding": {"loadContents": true},
+                        "source": "count-lines1-wf.cwl#step1/output",
+                        "type": "File"}],
+            "out": ["count-lines1-wf.cwl#step2/output"],
+            "outputs": [{"_tool_entry": {"id": "parseInt-tool.cwl#output",
+                                         "type": "int"},
+                         "id": "count-lines1-wf.cwl#step2/output",
+                         "type": "int"}],
+            "run": "parseInt-tool.cwl"}]}
diff --git a/tests/subgraph/single_step3.json b/tests/subgraph/single_step3.json
new file mode 100644
--- /dev/null
+++ b/tests/subgraph/single_step3.json
@@ -0,0 +1,24 @@
+{"class": "Workflow",
+ "cwlVersion": "v1.0",
+ "id": "count-lines1-wf.cwl",
+ "inputs": [{"id": "file1", "type": "Any"}],
+ "outputs": [{"id": "output",
+              "outputSource": "count-lines1-wf.cwl#step3/output",
+              "type": "Any"}],
+ "steps": [{"id": "count-lines1-wf.cwl#step3",
+            "in": [{"id": "count-lines1-wf.cwl#step3/file1",
+                    "source": "file1"}],
+            "inputs": [{"_tool_entry": {"id": "wc-tool.cwl#file1",
+                                        "type": "File"},
+                        "id": "count-lines1-wf.cwl#step3/file1",
+                        "source": "count-lines1-wf.cwl#file2",
+                        "type": "File"}],
+            "label": "step that is independent of step1 and step2",
+            "out": ["count-lines1-wf.cwl#step3/output"],
+            "outputs": [{"_tool_entry": {"id": "wc-tool.cwl#output",
+                                         "outputBinding": {"glob": "output"},
+                                         "type": "File"},
+                         "id": "count-lines1-wf.cwl#step3/output",
+                         "outputBinding": {"glob": "output"},
+                         "type": "File"}],
+            "run": "wc-tool.cwl"}]}
diff --git a/tests/subgraph/single_step4.json b/tests/subgraph/single_step4.json
new file mode 100644
--- /dev/null
+++ b/tests/subgraph/single_step4.json
@@ -0,0 +1,24 @@
+{"class": "Workflow",
+ "cwlVersion": "v1.0",
+ "id": "count-lines1-wf.cwl",
+ "inputs": [{"id": "file1", "type": "Any"}],
+ "outputs": [{"id": "output",
+              "outputSource": "count-lines1-wf.cwl#step4/output",
+              "type": "Any"}],
+ "steps": [{"id": "count-lines1-wf.cwl#step4",
+            "in": [{"id": "count-lines1-wf.cwl#step4/file1",
+                    "source": "file1"}],
+            "inputs": [{"_tool_entry": {"id": "parseInt-tool.cwl#file1",
+                                        "inputBinding": {"loadContents": true},
+                                        "type": "File"},
+                        "id": "count-lines1-wf.cwl#step4/file1",
+                        "inputBinding": {"loadContents": true},
+                        "source": "count-lines1-wf.cwl#step1/output",
+                        "type": "File"}],
+            "label": "step that also depends on step1",
+            "out": ["count-lines1-wf.cwl#step4/output"],
+            "outputs": [{"_tool_entry": {"id": "parseInt-tool.cwl#output",
+                                         "type": "int"},
+                         "id": "count-lines1-wf.cwl#step4/output",
+                         "type": "int"}],
+            "run": "parseInt-tool.cwl"}]}
diff --git a/tests/subgraph/single_step5.json b/tests/subgraph/single_step5.json
new file mode 100644
--- /dev/null
+++ b/tests/subgraph/single_step5.json
@@ -0,0 +1,30 @@
+{"class": "Workflow",
+ "cwlVersion": "v1.0",
+ "id": "count-lines1-wf.cwl",
+ "inputs": [{"id": "file1", "type": "Any"}, {"id": "file3", "type": "Any"}],
+ "outputs": [{"id": "output",
+              "outputSource": "count-lines1-wf.cwl#step5/output",
+              "type": "Any"}],
+ "steps": [{"id": "count-lines1-wf.cwl#step5",
+            "in": [{"id": "count-lines1-wf.cwl#step5/file1", "source": "file1"},
+                   {"id": "count-lines1-wf.cwl#step5/file3",
+                    "source": "file3"}],
+            "inputs": [{"_tool_entry": {"id": "wc-tool.cwl#file1",
+                                        "type": "File"},
+                        "id": "count-lines1-wf.cwl#step5/file1",
+                        "source": "count-lines1-wf.cwl#file1",
+                        "type": "File"},
+                       {"id": "count-lines1-wf.cwl#step5/file3",
+                        "not_connected": true,
+                        "source": "count-lines1-wf.cwl#file3",
+                        "type": "Any",
+                        "used_by_step": false}],
+            "label": "step with two inputs",
+            "out": ["count-lines1-wf.cwl#step5/output"],
+            "outputs": [{"_tool_entry": {"id": "wc-tool.cwl#output",
+                                         "outputBinding": {"glob": "output"},
+                                         "type": "File"},
+                         "id": "count-lines1-wf.cwl#step5/output",
+                         "outputBinding": {"glob": "output"},
+                         "type": "File"}],
+            "run": "wc-tool.cwl"}]}
diff --git a/tests/test_content_type.py b/tests/test_content_type.py
--- a/tests/test_content_type.py
+++ b/tests/test_content_type.py
@@ -1,9 +1,6 @@
 import pydot  # type: ignore
 
-
-from .util import (
-    get_main_output,
-)
+from .util import get_main_output
 
 
 def test_content_types() -> None:
diff --git a/tests/test_subgraph.py b/tests/test_subgraph.py
--- a/tests/test_subgraph.py
+++ b/tests/test_subgraph.py
@@ -6,7 +6,7 @@
 
 from cwltool.context import LoadingContext
 from cwltool.load_tool import load_tool
-from cwltool.subgraph import get_subgraph
+from cwltool.subgraph import get_step, get_subgraph
 from cwltool.workflow import Workflow, default_make_tool
 
 from .test_fetch import norm
@@ -49,3 +49,34 @@ def clean(val: Any) -> Any:
         extracted = get_subgraph([wf + "#" + a], tool)
         with open(get_data("tests/subgraph/extract_" + a + ".json")) as f:
             assert json.load(f) == clean(convert_to_dict(extracted))
+
+
+def test_get_step() -> None:
+    loadingContext = LoadingContext({"construct_tool_object": default_make_tool})
+    wf = norm(Path(get_data("tests/subgraph/count-lines1-wf.cwl")).as_uri())
+    loadingContext.do_update = False
+    tool = load_tool(wf, loadingContext)
+    assert isinstance(tool, Workflow)
+
+    sg = norm(Path(get_data("tests/subgraph")).as_uri())
+
+    def clean(val: Any) -> Any:
+        if isinstance(val, str):
+            if val.startswith(sg):
+                return val[len(sg) + 1 :]
+        if isinstance(val, dict):
+            return {k: clean(v) for k, v in val.items()}
+        if isinstance(val, list):
+            return [clean(v) for v in val]
+        return val
+
+    for a in (
+        "step1",
+        "step2",
+        "step3",
+        "step4",
+        "step5",
+    ):
+        extracted = get_step(tool, wf + "#" + a)
+        with open(get_data("tests/subgraph/single_" + a + ".json")) as f:
+            assert json.load(f) == clean(convert_to_dict(extracted))