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 🙏 

Discussion:

Patch:
diff --git a/pydra/engine/task.py b/pydra/engine/task.py
index 135af507..f808c037 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)
@@ -855,12 +861,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:


Test patch:
diff --git a/pydra/engine/tests/test_shelltask.py b/pydra/engine/tests/test_shelltask.py
index 79f1a8cb..8c4489f1 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,