/
nipype2boutiques.py
666 lines (584 loc) · 22.4 KB
/
nipype2boutiques.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
# This tool exports a Nipype interface in the Boutiques
# (https://github.com/boutiques) JSON format. Boutiques tools
# can be imported in CBRAIN (https://github.com/aces/cbrain)
# among other platforms.
#
# Limitations:
# * Optional outputs, i.e. outputs that not always produced, may not be
# detected. They will, however, still be listed with a placeholder for
# the path template (either a value key or the output ID) that should
# be verified and corrected.
# * Still need to add some fields to the descriptor manually, e.g. url,
# descriptor-url, path-template-stripped-extensions, etc.
import os
import sys
import simplejson as json
from ..scripts.instance import import_module
def generate_boutiques_descriptor(
module,
interface_name,
container_image,
container_type,
container_index=None,
verbose=False,
save=False,
save_path=None,
author=None,
ignore_inputs=None,
tags=None,
):
"""
Generate a JSON Boutiques description of a Nipype interface.
Arguments
---------
module :
module where the Nipype interface is declared.
interface_name :
name of Nipype interface.
container_image :
name of the container image where the tool is installed
container_type :
type of container image (Docker or Singularity)
container_index :
optional index where the image is available
verbose :
print information messages
save :
True if you want to save descriptor to a file
save_path :
file path for the saved descriptor (defaults to name of the
interface in current directory)
author :
author of the tool (required for publishing)
ignore_inputs :
list of interface inputs to not include in the descriptor
tags :
JSON object containing tags to include in the descriptor,
e.g. ``{"key1": "value1"}`` (note: the tags 'domain:neuroinformatics'
and 'interface-type:nipype' are included by default)
Returns
-------
boutiques : str
string containing a Boutiques' JSON object
"""
if not module:
raise Exception("Undefined module.")
# Retrieves Nipype interface
if isinstance(module, (str, bytes)):
import_module(module)
module_name = str(module)
module = sys.modules[module]
else:
module_name = str(module.__name__)
interface = getattr(module, interface_name)()
inputs = interface.input_spec()
outputs = interface.output_spec()
# Tool description
tool_desc = {}
tool_desc["name"] = interface_name
tool_desc["command-line"] = interface_name + " "
tool_desc["author"] = "Nipype (interface)"
if author is not None:
tool_desc["author"] = tool_desc["author"] + ", " + author + " (tool)"
tool_desc["description"] = (
interface_name
+ ", as implemented in Nipype (module: "
+ module_name
+ ", interface: "
+ interface_name
+ ")."
)
tool_desc["inputs"] = []
tool_desc["output-files"] = []
tool_desc["groups"] = []
tool_desc["tool-version"] = (
interface.version if interface.version is not None else "1.0.0"
)
tool_desc["schema-version"] = "0.5"
if container_image:
tool_desc["container-image"] = {}
tool_desc["container-image"]["image"] = container_image
tool_desc["container-image"]["type"] = container_type
if container_index:
tool_desc["container-image"]["index"] = container_index
# Generates tool inputs
for name, spec in sorted(interface.inputs.traits(transient=None).items()):
# Skip ignored inputs
if ignore_inputs is not None and name in ignore_inputs:
continue
# If spec has a name source, this means it actually represents an
# output, so create a Boutiques output from it
elif spec.name_source and spec.name_template:
tool_desc["output-files"].append(
get_boutiques_output_from_inp(inputs, spec, name)
)
else:
inp = get_boutiques_input(inputs, interface, name, spec, verbose)
# Handle compound inputs (inputs that can be of multiple types
# and are mutually exclusive)
if isinstance(inp, list):
mutex_group_members = []
tool_desc["command-line"] += inp[0]["value-key"] + " "
for i in inp:
tool_desc["inputs"].append(i)
mutex_group_members.append(i["id"])
if verbose:
print("-> Adding input " + i["name"])
# Put inputs into a mutually exclusive group
tool_desc["groups"].append(
{
"id": inp[0]["id"] + "_group",
"name": inp[0]["name"] + " group",
"members": mutex_group_members,
"mutually-exclusive": True,
}
)
else:
tool_desc["inputs"].append(inp)
tool_desc["command-line"] += inp["value-key"] + " "
if verbose:
print("-> Adding input " + inp["name"])
# Generates input groups
tool_desc["groups"] += get_boutiques_groups(
interface.inputs.traits(transient=None).items()
)
if len(tool_desc["groups"]) == 0:
del tool_desc["groups"]
# Generates tool outputs
generate_tool_outputs(outputs, interface, tool_desc, verbose, True)
# Generate outputs with various different inputs to try to generate
# as many output values as possible
custom_inputs = generate_custom_inputs(tool_desc["inputs"])
for input_dict in custom_inputs:
interface = getattr(module, interface_name)(**input_dict)
outputs = interface.output_spec()
generate_tool_outputs(outputs, interface, tool_desc, verbose, False)
# Fill in all missing output paths
for output in tool_desc["output-files"]:
if output["path-template"] == "":
fill_in_missing_output_path(output, output["name"], tool_desc["inputs"])
# Add tags
desc_tags = {"domain": "neuroinformatics", "source": "nipype-interface"}
if tags is not None:
tags_dict = json.loads(tags)
for k, v in tags_dict.items():
if k in desc_tags:
if not isinstance(desc_tags[k], list):
desc_tags[k] = [desc_tags[k]]
desc_tags[k].append(v)
else:
desc_tags[k] = v
tool_desc["tags"] = desc_tags
# Check for positional arguments and reorder command line args if necessary
tool_desc["command-line"] = reorder_cmd_line_args(
tool_desc["command-line"], interface, ignore_inputs
)
# Remove the extra space at the end of the command line
tool_desc["command-line"] = tool_desc["command-line"].strip()
# Save descriptor to a file
if save:
path = save_path or os.path.join(os.getcwd(), interface_name + ".json")
with open(path, "w") as outfile:
json.dump(tool_desc, outfile, indent=4, separators=(",", ": "))
if verbose:
print("-> Descriptor saved to file " + outfile.name)
print(
"NOTE: Descriptors produced by this script may not entirely conform "
"to the Nipype interface specs. Please check that the descriptor is "
"correct before using it."
)
return json.dumps(tool_desc, indent=4, separators=(",", ": "))
def generate_tool_outputs(outputs, interface, tool_desc, verbose, first_run):
for name, spec in sorted(outputs.traits(transient=None).items()):
output = get_boutiques_output(
outputs, name, spec, interface, tool_desc["inputs"]
)
# If this is the first time we are generating outputs, add the full
# output to the descriptor. Otherwise, find the existing output and
# update its path template if it's still undefined.
if first_run:
tool_desc["output-files"].append(output)
if output.get("value-key"):
tool_desc["command-line"] += output["value-key"] + " "
if verbose:
print("-> Adding output " + output["name"])
else:
for existing_output in tool_desc["output-files"]:
if (
output["id"] == existing_output["id"]
and existing_output["path-template"] == ""
):
existing_output["path-template"] = output["path-template"]
break
if (
output.get("value-key")
and output["value-key"] not in tool_desc["command-line"]
):
tool_desc["command-line"] += output["value-key"] + " "
if len(tool_desc["output-files"]) == 0:
raise Exception("Tool has no output.")
def get_boutiques_input(
inputs, interface, input_name, spec, verbose, handler=None, input_number=None
):
"""
Returns a dictionary containing the Boutiques input corresponding
to a Nipype input.
Args:
* inputs: inputs of the Nipype interface.
* interface: Nipype interface.
* input_name: name of the Nipype input.
* spec: Nipype input spec.
* verbose: print information messages.
* handler: used when handling compound inputs, which don't have their
own input spec
* input_number: used when handling compound inputs to assign each a
unique ID
Assumes that:
* Input names are unique.
"""
inp = {}
# No need to append a number to the first of a list of compound inputs
if input_number:
inp["id"] = input_name + "_" + str(input_number + 1)
else:
inp["id"] = input_name
inp["name"] = input_name.replace("_", " ").capitalize()
if handler is None:
trait_handler = spec.handler
else:
trait_handler = handler
# Figure out the input type from its handler type
handler_type = type(trait_handler).__name__
# Deal with compound traits
if handler_type == "TraitCompound":
input_list = []
# Recursively create an input for each trait
for i in range(0, len(trait_handler.handlers)):
inp = get_boutiques_input(
inputs,
interface,
input_name,
spec,
verbose,
trait_handler.handlers[i],
i,
)
inp["optional"] = True
input_list.append(inp)
return input_list
if handler_type == "File" or handler_type == "Directory":
inp["type"] = "File"
elif handler_type == "Int":
inp["type"] = "Number"
inp["integer"] = True
elif handler_type == "Float":
inp["type"] = "Number"
elif handler_type == "Bool":
inp["type"] = "Flag"
else:
inp["type"] = "String"
# Deal with range inputs
if handler_type == "Range":
inp["type"] = "Number"
if trait_handler._low is not None:
inp["minimum"] = trait_handler._low
if trait_handler._high is not None:
inp["maximum"] = trait_handler._high
if trait_handler._exclude_low:
inp["exclusive-minimum"] = True
if trait_handler._exclude_high:
inp["exclusive-maximum"] = True
# Deal with list inputs
# TODO handle lists of lists (e.g. FSL ProbTrackX seed input)
if handler_type == "List":
inp["list"] = True
item_type = trait_handler.item_trait.trait_type
item_type_name = type(item_type).__name__
if item_type_name == "Int":
inp["integer"] = True
inp["type"] = "Number"
elif item_type_name == "Float":
inp["type"] = "Number"
elif item_type_name == "File":
inp["type"] = "File"
elif item_type_name == "Enum":
value_choices = item_type.values
if value_choices is not None:
if all(isinstance(n, int) for n in value_choices):
inp["type"] = "Number"
inp["integer"] = True
elif all(isinstance(n, float) for n in value_choices):
inp["type"] = "Number"
inp["value-choices"] = value_choices
else:
inp["type"] = "String"
if trait_handler.minlen != 0:
inp["min-list-entries"] = trait_handler.minlen
if trait_handler.maxlen != sys.maxsize:
inp["max-list-entries"] = trait_handler.maxlen
if spec.sep:
inp["list-separator"] = spec.sep
if handler_type == "Tuple":
inp["list"] = True
inp["min-list-entries"] = len(spec.default)
inp["max-list-entries"] = len(spec.default)
input_type = type(spec.default[0]).__name__
if input_type == "int":
inp["type"] = "Number"
inp["integer"] = True
elif input_type == "float":
inp["type"] = "Number"
else:
inp["type"] = "String"
# Deal with multi-input
if handler_type == "InputMultiObject":
inp["type"] = "File"
inp["list"] = True
if spec.sep:
inp["list-separator"] = spec.sep
inp["value-key"] = (
"[" + input_name.upper() + "]"
) # assumes that input names are unique
flag, flag_sep = get_command_line_flag(spec, inp["type"] == "Flag", input_name)
if flag is not None:
inp["command-line-flag"] = flag
if flag_sep is not None:
inp["command-line-flag-separator"] = flag_sep
inp["description"] = get_description_from_spec(inputs, input_name, spec)
if not (hasattr(spec, "mandatory") and spec.mandatory):
inp["optional"] = True
else:
inp["optional"] = False
if spec.usedefault:
inp["default-value"] = spec.default_value()[1]
if spec.requires is not None:
inp["requires-inputs"] = spec.requires
try:
value_choices = trait_handler.values
except AttributeError:
pass
else:
if value_choices is not None:
if all(isinstance(n, int) for n in value_choices):
inp["type"] = "Number"
inp["integer"] = True
elif all(isinstance(n, float) for n in value_choices):
inp["type"] = "Number"
inp["value-choices"] = value_choices
return inp
def get_boutiques_output(outputs, name, spec, interface, tool_inputs):
"""
Returns a dictionary containing the Boutiques output corresponding
to a Nipype output.
Args:
* outputs: outputs of the Nipype interface.
* name: name of the Nipype output.
* spec: Nipype output spec.
* interface: Nipype interface.
* tool_inputs: list of tool inputs (as produced by method
get_boutiques_input).
Assumes that:
* Output names are unique.
* Input values involved in the path template are defined.
* Output files are written in the current directory.
* There is a single output value (output lists are not supported).
"""
output = {}
output["name"] = name.replace("_", " ").capitalize()
# Check if the output name was already used as an input name
# If so, append '_outfile' to the end of the ID
unique_id = True
for inp in tool_inputs:
if inp["id"] == name:
unique_id = False
break
output["id"] = name if unique_id else name + "_outfile"
output["path-template"] = ""
# No real way to determine if an output is always
# produced, regardless of the input values.
output["optional"] = True
output["description"] = get_description_from_spec(outputs, name, spec)
# Path template creation.
try:
output_value = interface._list_outputs()[name]
except TypeError:
output_value = None
except AttributeError:
output_value = None
except KeyError:
output_value = None
# Handle multi-outputs
if (
isinstance(output_value, list)
or type(spec.handler).__name__ == "OutputMultiObject"
or type(spec.handler).__name__ == "List"
):
output["list"] = True
if output_value:
# Check if all extensions are the same
extensions = []
for val in output_value:
extensions.append(os.path.splitext(val)[1])
# If extensions all the same, set path template as
# wildcard + extension. Otherwise just use a wildcard
if len(set(extensions)) == 1:
output["path-template"] = "*" + extensions[0]
else:
output["path-template"] = "*"
return output
# If an output value is defined, use its relative path, if one exists.
# Otherwise, put blank string as placeholder and try to fill it on
# another iteration.
if output_value:
output["path-template"] = os.path.relpath(output_value)
else:
output["path-template"] = ""
return output
def get_boutiques_groups(input_traits):
"""
Returns a list of dictionaries containing Boutiques groups for the mutually
exclusive Nipype inputs.
"""
desc_groups = []
mutex_input_sets = []
# Get all the groups
for name, spec in input_traits:
if spec.xor is not None:
group_members = set([name] + list(spec.xor))
if group_members not in mutex_input_sets:
mutex_input_sets.append(group_members)
# Create a dictionary for each one
for i, inp_set in enumerate(mutex_input_sets, 1):
desc_groups.append(
{
"id": "mutex_group" + ("_" + str(i) if i != 1 else ""),
"name": "Mutex group" + (" " + str(i) if i != 1 else ""),
"members": list(inp_set),
"mutually-exclusive": True,
}
)
return desc_groups
def get_description_from_spec(obj, name, spec):
"""
Generates a description based on the input or output spec.
"""
if not spec.desc:
spec.desc = "No description provided."
spec_info = spec.full_info(obj, name, None)
boutiques_description = (
spec_info.capitalize() + ". " + spec.desc.capitalize()
).replace("\n", "")
if not boutiques_description.endswith("."):
boutiques_description += "."
return boutiques_description
def fill_in_missing_output_path(output, output_name, tool_inputs):
"""
Creates a path template for outputs that are missing one
This is needed for the descriptor to be valid (path template is required)
"""
# Look for an input with the same name as the output and use its value key
found = False
for input in tool_inputs:
if input["name"] == output_name:
output["path-template"] = input["value-key"]
found = True
break
# If no input with the same name was found, use the output ID
if not found:
output["path-template"] = output["id"]
return output
def generate_custom_inputs(desc_inputs):
"""
Generates a bunch of custom input dictionaries in order to generate
as many outputs as possible (to get their path templates).
Currently only works with flag inputs and inputs with defined value
choices.
"""
custom_input_dicts = []
for desc_input in desc_inputs:
if desc_input["type"] == "Flag":
custom_input_dicts.append({desc_input["id"]: True})
elif desc_input.get("value-choices") and not desc_input.get("list"):
for value in desc_input["value-choices"]:
custom_input_dicts.append({desc_input["id"]: value})
return custom_input_dicts
def reorder_cmd_line_args(cmd_line, interface, ignore_inputs=None):
"""
Generates a new command line with the positional arguments in the
correct order
"""
interface_name = cmd_line.split()[0]
positional_arg_dict = {}
positional_args = []
non_positional_args = []
for name, spec in sorted(interface.inputs.traits(transient=None).items()):
if ignore_inputs is not None and name in ignore_inputs:
continue
value_key = "[" + name.upper() + "]"
if spec.position is not None:
positional_arg_dict[spec.position] = value_key
else:
non_positional_args.append(value_key)
last_arg = None
for item in sorted(positional_arg_dict.items()):
if item[0] == -1:
last_arg = item[1]
continue
positional_args.append(item[1])
return (
interface_name
+ " "
+ ((" ".join(positional_args) + " ") if len(positional_args) > 0 else "")
+ ((last_arg + " ") if last_arg else "")
+ " ".join(non_positional_args)
)
def get_command_line_flag(input_spec, is_flag_type=False, input_name=None):
"""
Generates the command line flag for a given input
"""
flag, flag_sep = None, None
if input_spec.argstr:
if "=" in input_spec.argstr:
if (
input_spec.argstr.split("=")[1] == "0"
or input_spec.argstr.split("=")[1] == "1"
):
flag = input_spec.argstr
else:
flag = input_spec.argstr.split("=")[0].strip()
flag_sep = "="
elif input_spec.argstr.split("%")[0]:
flag = input_spec.argstr.split("%")[0].strip()
elif is_flag_type:
flag = ("--%s" % input_name + " ").strip()
return flag, flag_sep
def get_boutiques_output_from_inp(inputs, inp_spec, inp_name):
"""
Takes a Nipype input representing an output file and generates a
Boutiques output for it
"""
output = {}
output["name"] = inp_name.replace("_", " ").capitalize()
output["id"] = inp_name
output["optional"] = True
output["description"] = get_description_from_spec(inputs, inp_name, inp_spec)
if not (hasattr(inp_spec, "mandatory") and inp_spec.mandatory):
output["optional"] = True
else:
output["optional"] = False
if inp_spec.usedefault:
output["default-value"] = inp_spec.default_value()[1]
if isinstance(inp_spec.name_source, list):
source = inp_spec.name_source[0]
else:
source = inp_spec.name_source
output["path-template"] = inp_spec.name_template.replace(
"%s", "[" + source.upper() + "]"
)
output["value-key"] = "[" + inp_name.upper() + "]"
flag, flag_sep = get_command_line_flag(inp_spec)
if flag is not None:
output["command-line-flag"] = flag
if flag_sep is not None:
output["command-line-flag-separator"] = flag_sep
return output