Repository: nipype/pydra

Issue:
Proper way to format a tuple parameter
I have got an interface which expects a parameter `foo` as a triple with the following format `--foo 1 2 3`

I reached for the following definition as a first attempt:

```python
input_spec = pydra.specs.SpecInfo(
    name="Input",
    fields=[
        (
            "foo",
            ty.Tuple[int, int, int],
            {
                "help_string": "foo triple",
                "argstr": "--foo {foo}",
            },
        ),
    ],
    bases=(pydra.specs.ShellSpec,),
```

Which does not work since it produces `--foo (1, 2, 3)`.

If I use `--foo...` and instantiate the interface with a list instead of tuple then I get `--foo 1 --foo 2 --foo 3`.

If I use `formatter` instead of `argstr` with `"formatter": lambda field: f"--foo {' '.join(field)}"`, I get an error because formatter seem to be  called with each element of the list instead of the entire list (`TypeError: sequence item 0: expected str instance, int found`).

I am probably missing another option, hence this support request 🙏 

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 37543f0..d4499fe 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,7 @@ repos:
     -   id: check-yaml
     -   id: check-added-large-files
 -   repo: https://github.com/psf/black
-    rev: 22.12.0
+    rev: 23.1.0
     hooks:
     -   id: black
 -   repo: https://github.com/codespell-project/codespell
diff --git a/pydra/__init__.py b/pydra/__init__.py
index cb6823d..0416c9b 100644
--- a/pydra/__init__.py
+++ b/pydra/__init__.py
@@ -23,7 +23,6 @@ from . import mark
 
 
 def check_latest_version():
-
     import etelemetry
 
     return etelemetry.check_available_version("nipype/pydra", __version__, lgr=logger)
diff --git a/pydra/engine/core.py b/pydra/engine/core.py
index e0ccd75..31127ca 100644
--- a/pydra/engine/core.py
+++ b/pydra/engine/core.py
@@ -696,7 +696,7 @@ class TaskBase:
 
     def _combined_output(self, return_inputs=False):
         combined_results = []
-        for (gr, ind_l) in self.state.final_combined_ind_mapping.items():
+        for gr, ind_l in self.state.final_combined_ind_mapping.items():
             combined_results_gr = []
             for ind in ind_l:
                 result = load_result(self.checksum_states(ind), self.cache_locations)
@@ -1253,7 +1253,7 @@ class Workflow(TaskBase):
             for task in self.graph.nodes:
                 self.create_connections(task, detailed=True)
             # adding wf outputs
-            for (wf_out, lf) in self._connections:
+            for wf_out, lf in self._connections:
                 self.graph.add_edges_description((self.name, wf_out, lf.name, lf.field))
             dotfile = self.graph.create_dotfile_detailed(outdir=outdir, name=name)
         else:
diff --git a/pydra/engine/graph.py b/pydra/engine/graph.py
index 01de5cc..7b2724e 100644
--- a/pydra/engine/graph.py
+++ b/pydra/engine/graph.py
@@ -85,7 +85,7 @@ class DiGraph:
         """Add edges to the graph (nodes should be already set)."""
         if edges:
             edges = ensure_list(edges)
-            for (nd_out, nd_in) in edges:
+            for nd_out, nd_in in edges:
                 if nd_out not in self.nodes or nd_in not in self.nodes:
                     raise Exception(
                         f"edge {(nd_out, nd_in)} can't be added to the graph"
@@ -131,7 +131,7 @@ class DiGraph:
             self.predecessors[nd.name] = []
             self.successors[nd.name] = []
 
-        for (nd_out, nd_in) in self.edges:
+        for nd_out, nd_in in self.edges:
             self.predecessors[nd_in.name].append(nd_out)
             self.successors[nd_out.name].append(nd_in)
 
@@ -148,7 +148,7 @@ class DiGraph:
     def add_edges(self, new_edges):
         """Add new edges and sort the new graph."""
         self.edges = self._edges + ensure_list(new_edges)
-        for (nd_out, nd_in) in ensure_list(new_edges):
+        for nd_out, nd_in in ensure_list(new_edges):
             self.predecessors[nd_in.name].append(nd_out)
             self.successors[nd_out.name].append(nd_in)
         if self._sorted_nodes is not None:
diff --git a/pydra/engine/helpers_state.py b/pydra/engine/helpers_state.py
index d4e527b..4eb9248 100644
--- a/pydra/engine/helpers_state.py
+++ b/pydra/engine/helpers_state.py
@@ -237,7 +237,7 @@ def remove_inp_from_splitter_rpn(splitter_rpn, inputs_to_remove):
     stack_inp = []
     stack_sgn = []
     from_last_sign = []
-    for (ii, el) in enumerate(splitter_rpn_copy):
+    for ii, el in enumerate(splitter_rpn_copy):
         # element is a sign
         if el == "." or el == "*":
             stack_sgn.append((ii, el))
@@ -589,7 +589,7 @@ def combine_final_groups(combiner, groups, groups_stack, keys):
     gr_final = list(gr_final)
     map_gr_nr = {nr: i for (i, nr) in enumerate(sorted(gr_final))}
     groups_final_map = {}
-    for (inp, gr) in groups_final.items():
+    for inp, gr in groups_final.items():
         if isinstance(gr, int):
             groups_final_map[inp] = map_gr_nr[gr]
         elif isinstance(gr, list):
diff --git a/pydra/engine/task.py b/pydra/engine/task.py
index 135af50..06f5133 100644
--- a/pydra/engine/task.py
+++ b/pydra/engine/task.py
@@ -371,7 +371,7 @@ class ShellCommandTask(TaskBase):
         return value
 
     def _command_shelltask_executable(self, field):
-        """Returining position and value for executable ShellTask input"""
+        """Returning position and value for executable ShellTask input"""
         pos = 0  # executable should be the first el. of the command
         value = self._field_value(field)
         if value is None:
@@ -379,7 +379,7 @@ class ShellCommandTask(TaskBase):
         return pos, ensure_list(value, tuple2list=True)
 
     def _command_shelltask_args(self, field):
-        """Returining position and value for args ShellTask input"""
+        """Returning position and value for args ShellTask input"""
         pos = -1  # assuming that args is the last el. of the command
         value = self._field_value(field, check_file=True)
         if value is None:
@@ -396,7 +396,7 @@ class ShellCommandTask(TaskBase):
         argstr = field.metadata.get("argstr", None)
         formatter = field.metadata.get("formatter", None)
         if argstr is None and formatter is None:
-            # assuming that input that has no arstr is not used in the command,
+            # assuming that input that has no argstr is not used in the command,
             # or a formatter is not provided too.
             return None
         pos = field.metadata.get("position", None)
@@ -429,7 +429,7 @@ class ShellCommandTask(TaskBase):
 
         cmd_add = []
         # formatter that creates a custom command argument
-        # it can thake the value of the filed, all inputs, or the value of other fields.
+        # it can take the value of the field, all inputs, or the value of other fields.
         if "formatter" in field.metadata:
             call_args = inspect.getfullargspec(field.metadata["formatter"])
             call_args_val = {}
@@ -453,12 +453,16 @@ class ShellCommandTask(TaskBase):
                 cmd_add += split_cmd(cmd_el_str)
         elif field.type is bool:
             # if value is simply True the original argstr is used,
-            # if False, nothing is added to the command
+            # if False, nothing is added to the command.
             if value is True:
                 cmd_add.append(argstr)
         else:
             sep = field.metadata.get("sep", " ")
-            if argstr.endswith("...") and isinstance(value, list):
+            if (
+                argstr.endswith("...")
+                and isinstance(value, ty.Iterable)
+                and not isinstance(value, (str, bytes))
+            ):
                 argstr = argstr.replace("...", "")
                 # if argstr has a more complex form, with "{input_field}"
                 if "{" in argstr and "}" in argstr:
@@ -474,7 +478,9 @@ class ShellCommandTask(TaskBase):
             else:
                 # in case there are ... when input is not a list
                 argstr = argstr.replace("...", "")
-                if isinstance(value, list):
+                if isinstance(value, ty.Iterable) and not isinstance(
+                    value, (str, bytes)
+                ):
                     cmd_el_str = sep.join([str(val) for val in value])
                     value = cmd_el_str
                 # if argstr has a more complex form, with "{input_field}"
@@ -505,10 +511,10 @@ class ShellCommandTask(TaskBase):
             command_args = self.container_args + self.command_args
         else:
             command_args = self.command_args
-        # Skip the executable, which can be a multi-part command, e.g. 'docker run'.
+        # Skip the executable, which can be a multipart command, e.g. 'docker run'.
         cmdline = command_args[0]
         for arg in command_args[1:]:
-            # If there are spaces in the arg and it is not enclosed by matching
+            # If there are spaces in the arg, and it is not enclosed by matching
             # quotes, add quotes to escape the space. Not sure if this should
             # be expanded to include other special characters apart from spaces
             if " " in arg:
@@ -600,7 +606,7 @@ class ContainerTask(ShellCommandTask):
     def _field_value(self, field, check_file=False):
         """
         Checking value of the specific field, if value is not set, None is returned.
-        If check_file is True, checking if field is a a local file
+        If check_file is True, checking if field is a local file
         and settings bindings if needed.
         """
         value = super()._field_value(field)
@@ -654,7 +660,6 @@ class ContainerTask(ShellCommandTask):
                 or "pydra.engine.specs.File" in str(fld.type)
                 or "pydra.engine.specs.Directory" in str(fld.type)
             ):
-
                 if fld.name == "image":
                     continue
                 file = Path(getattr(self.inputs, fld.name))
@@ -855,12 +860,12 @@ def split_cmd(cmd: str):
     str
         the command line string split into process args
     """
-    # Check whether running on posix or windows system
+    # Check whether running on posix or Windows system
     on_posix = platform.system() != "Windows"
     args = shlex.split(cmd, posix=on_posix)
     cmd_args = []
     for arg in args:
-        match = re.match("('|\")(.*)\\1$", arg)
+        match = re.match("(['\"])(.*)\\1$", arg)
         if match:
             cmd_args.append(match.group(2))
         else:
diff --git a/pydra/engine/workers.py b/pydra/engine/workers.py
index 54b8129..152a868 100644
--- a/pydra/engine/workers.py
+++ b/pydra/engine/workers.py
@@ -758,7 +758,6 @@ class SGEWorker(DistributedWorker):
         self.output_by_jobid[jobid] = (rc, stdout, stderr)
 
         for task_pkl, ind, rerun in tasks_to_run:
-
             self.jobid_by_task_uid[Path(task_pkl).parent.name] = jobid
 
         if error_file:
@@ -813,7 +812,6 @@ class SGEWorker(DistributedWorker):
                     await asyncio.sleep(self.poll_delay)
 
     async def _poll_job(self, jobid, cache_dir):
-
         cmd = (f"qstat", "-j", jobid)
         logger.debug(f"Polling job {jobid}")
         rc, stdout, stderr = await read_and_display_async(*cmd, hide_display=True)


Test patch:
diff --git a/pydra/engine/tests/test_shelltask.py b/pydra/engine/tests/test_shelltask.py
index 79f1a8c..f77dd06 100644
--- a/pydra/engine/tests/test_shelltask.py
+++ b/pydra/engine/tests/test_shelltask.py
@@ -1674,6 +1674,40 @@ def test_shell_cmd_inputspec_12(tmpdir, plugin, results_function):
     assert shelly.output_dir == res.output.file_copy.parent
 
 
+def test_shell_cmd_inputspec_with_iterable():
+    """Test formatting of argstr with different iterable types."""
+
+    input_spec = SpecInfo(
+        name="Input",
+        fields=[
+            (
+                "iterable_1",
+                ty.Iterable[int],
+                {
+                    "help_string": "iterable input 1",
+                    "argstr": "--in1",
+                },
+            ),
+            (
+                "iterable_2",
+                ty.Iterable[str],
+                {
+                    "help_string": "iterable input 2",
+                    "argstr": "--in2...",
+                },
+            ),
+        ],
+        bases=(ShellSpec,),
+    )
+
+    task = ShellCommandTask(name="test", input_spec=input_spec, executable="test")
+
+    for iterable_type in (list, tuple):
+        task.inputs.iterable_1 = iterable_type(range(3))
+        task.inputs.iterable_2 = iterable_type(["bar", "foo"])
+        assert task.cmdline == "test --in1 0 1 2 --in2 bar --in2 foo"
+
+
 @pytest.mark.parametrize("results_function", [result_no_submitter, result_submitter])
 def test_shell_cmd_inputspec_copyfile_1(plugin, results_function, tmpdir):
     """shelltask changes a file in place,
@@ -3251,6 +3285,7 @@ def test_shell_cmd_outputspec_8d(tmpdir, plugin, results_function):
     """
     customised output_spec, adding Directory to the output named by input spec
     """
+
     # For /tmp/some_dict/test this function returns "/test"
     def get_lowest_directory(directory_path):
         return str(directory_path).replace(str(Path(directory_path).parents[0]), "")
@@ -4395,7 +4430,8 @@ def test_shell_cmd_non_existing_outputs_1(tmpdir):
 
 def test_shell_cmd_non_existing_outputs_2(tmpdir):
     """Checking that non existing output files do not return a phantom path,
-    but return NOTHING instead. This test has one existing and one non existing output file."""
+    but return NOTHING instead. This test has one existing and one non existing output file.
+    """
     input_spec = SpecInfo(
         name="Input",
         fields=[
@@ -4460,7 +4496,8 @@ def test_shell_cmd_non_existing_outputs_2(tmpdir):
 
 def test_shell_cmd_non_existing_outputs_3(tmpdir):
     """Checking that non existing output files do not return a phantom path,
-    but return NOTHING instead. This test has an existing mandatory output and another non existing output file."""
+    but return NOTHING instead. This test has an existing mandatory output and another non existing output file.
+    """
     input_spec = SpecInfo(
         name="Input",
         fields=[
@@ -4886,7 +4923,6 @@ def test_shellspec_formatter_splitter_2(tmpdir):
 
 @no_win
 def test_shellcommand_error_msg(tmpdir):
-
     script_path = Path(tmpdir) / "script.sh"
 
     with open(script_path, "w") as f: