Skip to content

Commit 75d429b

Browse files
committed
fixed up templating defaults
1 parent 53f28bd commit 75d429b

File tree

4 files changed

+108
-44
lines changed

4 files changed

+108
-44
lines changed

docs/source/tutorial/shell.ipynb

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,54 +11,70 @@
1111
"cell_type": "markdown",
1212
"metadata": {},
1313
"source": [
14-
"## Command-line template\n",
14+
"## Command-line templates\n",
1515
"\n",
16-
"Define a shell-task specification using a command template string. Input and output fields are both specified by placing the name of the field within enclosing `<` and `>`. Outputs are differentiated by the `out|` prefix."
16+
"Shell task specs can be defined using from string templates that resemble the command-line usage examples typically used in in-line help. Therefore, they can be quick and intuitive way to specify a shell task. For example, a simple spec for the copy command `cp` that omits optional flags,"
1717
]
1818
},
1919
{
2020
"cell_type": "code",
21-
"execution_count": 5,
21+
"execution_count": 2,
22+
"metadata": {},
23+
"outputs": [],
24+
"source": [
25+
"from pydra.design import shell\n",
26+
"\n",
27+
"Cp = shell.define(\"cp <in_file> <out|destination>\")"
28+
]
29+
},
30+
{
31+
"cell_type": "markdown",
32+
"metadata": {},
33+
"source": [
34+
"Input and output fields are both specified by placing the name of the field within enclosing `<` and `>`. Outputs are differentiated by the `out|` prefix.\n",
35+
"\n",
36+
"This shell task can then be run just as a Python task would be run, first parameterising it, then executing"
37+
]
38+
},
39+
{
40+
"cell_type": "code",
41+
"execution_count": 9,
2242
"metadata": {},
2343
"outputs": [
2444
{
2545
"name": "stdout",
2646
"output_type": "stream",
2747
"text": [
28-
"[outarg(name='out_file', type=<class 'fileformats.generic.fsobject.FsObject'>, default=EMPTY, help_string='', requires=[], converter=None, validator=None, xor=(), copy_mode=<CopyMode.any: 15>, copy_collation=<CopyCollation.any: 0>, copy_ext_decomp=<ExtensionDecomposition.single: 1>, readonly=False, argstr='', position=1, sep=None, allowed_values=None, container_path=False, formatter=None, path_template='out_file'), arg(name='executable', type=typing.Union[str, typing.Sequence[str]], default='cp', help_string=\"the first part of the command, can be a string, e.g. 'ls', or a list, e.g. ['ls', '-l', 'dirname']\", requires=[], converter=None, validator=<min_len validator for 1>, xor=(), copy_mode=<CopyMode.any: 15>, copy_collation=<CopyCollation.any: 0>, copy_ext_decomp=<ExtensionDecomposition.single: 1>, readonly=False, argstr='', position=0, sep=None, allowed_values=None, container_path=False, formatter=None)]\n"
29-
]
30-
},
31-
{
32-
"ename": "TypeError",
33-
"evalue": "cp.__init__() got an unexpected keyword argument 'in_file'",
34-
"output_type": "error",
35-
"traceback": [
36-
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
37-
"\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)",
38-
"Cell \u001b[0;32mIn[5], line 13\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[38;5;28mprint\u001b[39m(list_fields(Cp))\n\u001b[1;32m 12\u001b[0m \u001b[38;5;66;03m# Parameterise the task spec\u001b[39;00m\n\u001b[0;32m---> 13\u001b[0m cp \u001b[38;5;241m=\u001b[39m \u001b[43mCp\u001b[49m\u001b[43m(\u001b[49m\u001b[43min_file\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtest_file\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mout_file\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43m./out.txt\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;66;03m# Print the cmdline to be run to double check\u001b[39;00m\n\u001b[1;32m 16\u001b[0m \u001b[38;5;28mprint\u001b[39m(cp\u001b[38;5;241m.\u001b[39mcmdline)\n",
39-
"\u001b[0;31mTypeError\u001b[0m: cp.__init__() got an unexpected keyword argument 'in_file'"
48+
"Command-line to be run: cp /var/folders/mz/yn83q2fd3s758w1j75d2nnw80000gn/T/tmpnw4kzvv0/in.txt /var/folders/mz/yn83q2fd3s758w1j75d2nnw80000gn/T/tmpnw4kzvv0/out.txt\n",
49+
"Contents of copied file ('/var/folders/mz/yn83q2fd3s758w1j75d2nnw80000gn/T/tmpnw4kzvv0/out.txt'): 'Contents to be copied'\n"
4050
]
4151
}
4252
],
4353
"source": [
54+
"from pathlib import Path\n",
55+
"from tempfile import mkdtemp\n",
4456
"from pydra.design import shell\n",
4557
"from pydra.engine.helpers import list_fields\n",
4658
"\n",
47-
"test_file = \"./in.txt\"\n",
59+
"# Make a test file to copy\n",
60+
"test_dir = Path(mkdtemp())\n",
61+
"test_file = test_dir / \"in.txt\"\n",
4862
"with open(test_file, \"w\") as f:\n",
49-
" f.write(\"this is a test file\\n\")\n",
50-
"\n",
51-
"# Define the shell-command task specification\n",
52-
"Cp = shell.define(\"cp <in_file> <out|out_file>\")\n",
63+
" f.write(\"Contents to be copied\")\n",
5364
"\n",
5465
"# Parameterise the task spec\n",
55-
"cp = Cp(in_file=test_file, out_file=\"./out.txt\")\n",
66+
"cp = Cp(in_file=test_file, destination=test_dir / \"out.txt\")\n",
5667
"\n",
5768
"# Print the cmdline to be run to double check\n",
58-
"print(cp.cmdline)\n",
69+
"print(f\"Command-line to be run: {cp.cmdline}\")\n",
5970
"\n",
6071
"# Run the shell-comand task\n",
61-
"cp()"
72+
"result = cp()\n",
73+
"\n",
74+
"print(\n",
75+
" f\"Contents of copied file ('{result.output.destination}'): \"\n",
76+
" f\"'{Path(result.output.destination).read_text()}'\"\n",
77+
")"
6278
]
6379
},
6480
{
@@ -70,9 +86,17 @@
7086
},
7187
{
7288
"cell_type": "code",
73-
"execution_count": null,
89+
"execution_count": 10,
7490
"metadata": {},
75-
"outputs": [],
91+
"outputs": [
92+
{
93+
"name": "stdout",
94+
"output_type": "stream",
95+
"text": [
96+
"cp /var/folders/mz/yn83q2fd3s758w1j75d2nnw80000gn/T/tmpnw4kzvv0/in.txt True\n"
97+
]
98+
}
99+
],
76100
"source": [
77101
"cp = Cp(in_file=test_file)\n",
78102
"print(cp.cmdline)"

pydra/design/shell.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ class outarg(Out, arg):
189189
"""
190190

191191
path_template: str | None = attrs.field(default=None)
192+
keep_extension: bool = attrs.field(default=False)
192193

193194
@path_template.validator
194195
def _validate_path_template(self, attribute, value):
@@ -198,6 +199,14 @@ def _validate_path_template(self, attribute, value):
198199
f"({self.default!r}) is provided"
199200
)
200201

202+
@keep_extension.validator
203+
def _validate_keep_extension(self, attribute, value):
204+
if value and self.path_template is not None:
205+
raise ValueError(
206+
f"keep_extension ({value!r}) can only be provided when path_template "
207+
f"is provided"
208+
)
209+
201210

202211
@dataclass_transform(
203212
kw_only_default=True,
@@ -465,7 +474,7 @@ def parse_command_line_template(
465474
outputs = {}
466475
parts = template.split()
467476
executable = []
468-
for i, part in enumerate(parts, start=1):
477+
for i, part in enumerate(parts):
469478
if part.startswith("<") or part.startswith("-"):
470479
break
471480
executable.append(part)

pydra/engine/helpers_file.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from contextlib import contextmanager
1111
import attr
1212
from fileformats.core import FileSet
13-
from pydra.engine.helpers import is_lazy, attrs_values
13+
from pydra.engine.helpers import is_lazy, attrs_values, list_fields
1414

1515

1616
logger = logging.getLogger("pydra")
@@ -114,17 +114,18 @@ def template_update(inputs, output_dir, state_ind=None, map_copyfiles=None):
114114
k = k.split(".")[1]
115115
inputs_dict_st[k] = inputs_dict_st[k][v]
116116

117-
from .specs import attrs_fields
117+
from pydra.design import shell
118118

119119
# Collect templated inputs for which all requirements are satisfied.
120120
fields_templ = [
121121
field
122-
for field in attrs_fields(inputs)
123-
if field.metadata.get("output_file_template")
122+
for field in list_fields(inputs)
123+
if isinstance(field, shell.outarg)
124+
and field.path_template
124125
and getattr(inputs, field.name) is not False
125126
and all(
126-
getattr(inputs, required_field) is not attr.NOTHING
127-
for required_field in field.metadata.get("requires", ())
127+
getattr(inputs, required_field) is not None
128+
for required_field in field.requires
128129
)
129130
]
130131

@@ -151,8 +152,7 @@ def template_update_single(
151152
"""
152153
# if input_dict_st with state specific value is not available,
153154
# the dictionary will be created from inputs object
154-
from pydra.utils.typing import TypeParser # noqa
155-
from pydra.engine.specs import OUTPUT_TEMPLATE_TYPES
155+
from pydra.utils.typing import TypeParser, OUTPUT_TEMPLATE_TYPES # noqa
156156

157157
if inputs_dict_st is None:
158158
inputs_dict_st = attrs_values(inputs)
@@ -200,9 +200,23 @@ def _template_formatting(field, inputs, inputs_dict_st):
200200
returning a list of formatted templates in that case.
201201
Allowing for multiple input values used in the template as longs as
202202
there is no more than one file (i.e. File, PathLike or string with extensions)
203+
204+
Parameters
205+
----------
206+
field : pydra.engine.helpers.Field
207+
field with a template
208+
inputs : pydra.engine.helpers.Input
209+
inputs object
210+
inputs_dict_st : dict
211+
dictionary with values from inputs object
212+
213+
Returns
214+
-------
215+
formatted : str or list
216+
formatted template
203217
"""
204218
# if a template is a function it has to be run first with the inputs as the only arg
205-
template = field.metadata["output_file_template"]
219+
template = field.path_template
206220
if callable(template):
207221
template = template(inputs)
208222

@@ -219,9 +233,8 @@ def _template_formatting(field, inputs, inputs_dict_st):
219233

220234

221235
def _string_template_formatting(field, template, inputs, inputs_dict_st):
222-
from .specs import MultiInputObj, MultiOutputFile
236+
from pydra.utils.typing import MultiInputObj, MultiOutputFile
223237

224-
keep_extension = field.metadata.get("keep_extension", True)
225238
inp_fields = re.findall(r"{\w+}", template)
226239
inp_fields_fl = re.findall(r"{\w+:[0-9.]+f}", template)
227240
inp_fields += [re.sub(":[0-9.]+f", "", el) for el in inp_fields_fl]
@@ -281,17 +294,25 @@ def _string_template_formatting(field, template, inputs, inputs_dict_st):
281294

282295
formatted_value.append(
283296
_element_formatting(
284-
template, val_dict_el, file_template, keep_extension=keep_extension
297+
template,
298+
val_dict_el,
299+
file_template,
300+
keep_extension=field.keep_extension,
285301
)
286302
)
287303
else:
288304
formatted_value = _element_formatting(
289-
template, val_dict, file_template, keep_extension=keep_extension
305+
template, val_dict, file_template, keep_extension=field.keep_extension
290306
)
291307
return formatted_value
292308

293309

294-
def _element_formatting(template, values_template_dict, file_template, keep_extension):
310+
def _element_formatting(
311+
template: str,
312+
values_template_dict: dict[str, ty.Any],
313+
file_template: str,
314+
keep_extension: bool,
315+
):
295316
"""Formatting a single template for a single element (if a list).
296317
Taking into account that a file used in the template (file_template)
297318
and the template itself could have file extensions

pydra/engine/specs.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ def from_task(
494494
)
495495
# Get the corresponding value from the inputs if it exists, which will be
496496
# passed through to the outputs, to permit manual overrides
497-
if isinstance(fld, shell.outarg) and is_set(getattr(task.inputs, fld.name)):
497+
if isinstance(fld, shell.outarg) and is_set(getattr(task.spec, fld.name)):
498498
resolved_value = getattr(task.spec, fld.name)
499499
elif is_set(fld.default):
500500
resolved_value = cls._resolve_default_value(fld, task.output_dir)
@@ -691,10 +691,20 @@ def _command_args(
691691
else:
692692
if name in modified_inputs:
693693
pos_val = self._command_pos_args(
694-
field, value, output_dir, root=root
694+
field=field,
695+
value=value,
696+
inputs=inputs,
697+
root=root,
698+
output_dir=output_dir,
695699
)
696700
else:
697-
pos_val = self._command_pos_args(field, value, output_dir, inputs)
701+
pos_val = self._command_pos_args(
702+
field=field,
703+
value=value,
704+
output_dir=output_dir,
705+
inputs=inputs,
706+
root=root,
707+
)
698708
if pos_val:
699709
pos_args.append(pos_val)
700710

@@ -755,7 +765,7 @@ def _command_pos_args(
755765
# Shift negatives down to allow args to be -1
756766
field.position += 1 if field.position >= 0 else -1
757767

758-
if value:
768+
if value and isinstance(value, str):
759769
if root: # values from templates
760770
value = value.replace(str(output_dir), f"{root}{output_dir}")
761771

0 commit comments

Comments
 (0)