diff --git a/nipype/scripts/cli.py b/nipype/scripts/cli.py index 59d8672cfb..482f359aae 100644 --- a/nipype/scripts/cli.py +++ b/nipype/scripts/cli.py @@ -211,48 +211,55 @@ def convert(): help="JSON file name where the Boutiques descriptor will be " "written.") @click.option( - "-t", - "--ignored-template-inputs", + "-c", + "--container-image", + required=True, type=str, - multiple=True, - help="Interface inputs ignored in path template creations.") + help="Name of the container image where the tool is installed.") @click.option( - "-d", - "--docker-image", + "-p", + "--container-type", + required=True, type=str, - help="Name of the Docker image where the Nipype interface is " - "available.") + help="Type of container image (Docker or Singularity).") @click.option( - "-r", - "--docker-index", + "-x", + "--container-index", type=str, - help="Docker index where the Docker image is stored (e.g. " + help="Optional index where the image is available (e.g. " "http://index.docker.io).") @click.option( - "-n", - "--ignore-template-numbers", - is_flag=True, - flag_value=True, - help="Ignore all numbers in path template creations.") + "-g", + "--ignore-inputs", + type=str, + multiple=True, + help="List of interface inputs to not include in the descriptor.") @click.option( "-v", "--verbose", is_flag=True, flag_value=True, - help="Enable verbose output.") -def boutiques(interface, module, output, ignored_template_inputs, docker_image, - docker_index, ignore_template_numbers, verbose): + help="Print information messages.") +@click.option( + "-a", + "--author", + type=str, + help="Author of the tool (required for publishing).") +@click.option( + "-t", + "--tags", + type=str, + help="JSON string containing tags to include in the descriptor," + "e.g. \"{\"key1\": \"value1\"}\"") +def boutiques(module, interface, container_image, container_type, output, + container_index, verbose, author, ignore_inputs, tags): """Nipype to Boutiques exporter. See Boutiques specification at https://github.com/boutiques/schema. """ from nipype.utils.nipype2boutiques import generate_boutiques_descriptor - # Generates JSON string - json_string = generate_boutiques_descriptor( - module, interface, ignored_template_inputs, docker_image, docker_index, - verbose, ignore_template_numbers) - - # Writes JSON string to file - with open(output, 'w') as f: - f.write(json_string) + # Generates JSON string and saves it to file + generate_boutiques_descriptor( + module, interface, container_image, container_type, container_index, + verbose, True, output, author, ignore_inputs, tags) diff --git a/nipype/testing/data/nipype2boutiques_example.json b/nipype/testing/data/nipype2boutiques_example.json new file mode 100644 index 0000000000..45359f49ed --- /dev/null +++ b/nipype/testing/data/nipype2boutiques_example.json @@ -0,0 +1,549 @@ +{ + "name": "FLIRT", + "command-line": "FLIRT [IN_FILE] [REFERENCE] [OUT_FILE] [OUT_MATRIX_FILE] [ANGLE_REP] [APPLY_ISOXFM] [APPLY_XFM] [BBRSLOPE] [BBRTYPE] [BGVALUE] [BINS] [COARSE_SEARCH] [COST] [COST_FUNC] [DATATYPE] [DISPLAY_INIT] [DOF] [ECHOSPACING] [FIELDMAP] [FIELDMAPMASK] [FINE_SEARCH] [FORCE_SCALING] [IN_MATRIX_FILE] [IN_WEIGHT] [INTERP] [MIN_SAMPLING] [NO_CLAMP] [NO_RESAMPLE] [NO_RESAMPLE_BLUR] [NO_SEARCH] [OUT_LOG] [PADDING_SIZE] [PEDIR] [REF_WEIGHT] [RIGID2D] [SAVE_LOG] [SCHEDULE] [SEARCHR_X] [SEARCHR_Y] [SEARCHR_Z] [SINC_WIDTH] [SINC_WINDOW] [USES_QFORM] [VERBOSE] [WM_SEG] [WMCOORDS] [WMNORMS]", + "author": "Nipype (interface), Oxford Centre for Functional MRI of the Brain (FMRIB) (tool)", + "description": "FLIRT, as implemented in Nipype (module: nipype.interfaces.fsl, interface: FLIRT).", + "inputs": [ + { + "id": "angle_rep", + "name": "Angle rep", + "type": "String", + "value-key": "[ANGLE_REP]", + "command-line-flag": "-anglerep", + "description": "'quaternion' or 'euler'. Representation of rotation angles.", + "optional": true, + "value-choices": [ + "quaternion", + "euler" + ] + }, + { + "id": "apply_isoxfm", + "name": "Apply isoxfm", + "type": "Number", + "value-key": "[APPLY_ISOXFM]", + "command-line-flag": "-applyisoxfm", + "description": "A float. As applyxfm but forces isotropic resampling.", + "optional": true + }, + { + "id": "apply_xfm", + "name": "Apply xfm", + "type": "Flag", + "value-key": "[APPLY_XFM]", + "command-line-flag": "-applyxfm", + "description": "A boolean. Apply transformation supplied by in_matrix_file or uses_qform to use the affine matrix stored in the reference header.", + "optional": true + }, + { + "id": "bbrslope", + "name": "Bbrslope", + "type": "Number", + "value-key": "[BBRSLOPE]", + "command-line-flag": "-bbrslope", + "description": "A float. Value of bbr slope.", + "optional": true + }, + { + "id": "bbrtype", + "name": "Bbrtype", + "type": "String", + "value-key": "[BBRTYPE]", + "command-line-flag": "-bbrtype", + "description": "'signed' or 'global_abs' or 'local_abs'. Type of bbr cost function: signed [default], global_abs, local_abs.", + "optional": true, + "value-choices": [ + "signed", + "global_abs", + "local_abs" + ] + }, + { + "id": "bgvalue", + "name": "Bgvalue", + "type": "Number", + "value-key": "[BGVALUE]", + "command-line-flag": "-setbackground", + "description": "A float. Use specified background value for points outside fov.", + "optional": true + }, + { + "id": "bins", + "name": "Bins", + "type": "Number", + "integer": true, + "value-key": "[BINS]", + "command-line-flag": "-bins", + "description": "An integer (int or long). Number of histogram bins.", + "optional": true + }, + { + "id": "coarse_search", + "name": "Coarse search", + "type": "Number", + "integer": true, + "value-key": "[COARSE_SEARCH]", + "command-line-flag": "-coarsesearch", + "description": "An integer (int or long). Coarse search delta angle.", + "optional": true + }, + { + "id": "cost", + "name": "Cost", + "type": "String", + "value-key": "[COST]", + "command-line-flag": "-cost", + "description": "'mutualinfo' or 'corratio' or 'normcorr' or 'normmi' or 'leastsq' or 'labeldiff' or 'bbr'. Cost function.", + "optional": true, + "value-choices": [ + "mutualinfo", + "corratio", + "normcorr", + "normmi", + "leastsq", + "labeldiff", + "bbr" + ] + }, + { + "id": "cost_func", + "name": "Cost func", + "type": "String", + "value-key": "[COST_FUNC]", + "command-line-flag": "-searchcost", + "description": "'mutualinfo' or 'corratio' or 'normcorr' or 'normmi' or 'leastsq' or 'labeldiff' or 'bbr'. Cost function.", + "optional": true, + "value-choices": [ + "mutualinfo", + "corratio", + "normcorr", + "normmi", + "leastsq", + "labeldiff", + "bbr" + ] + }, + { + "id": "datatype", + "name": "Datatype", + "type": "String", + "value-key": "[DATATYPE]", + "command-line-flag": "-datatype", + "description": "'char' or 'short' or 'int' or 'float' or 'double'. Force output data type.", + "optional": true, + "value-choices": [ + "char", + "short", + "int", + "float", + "double" + ] + }, + { + "id": "display_init", + "name": "Display init", + "type": "Flag", + "value-key": "[DISPLAY_INIT]", + "command-line-flag": "-displayinit", + "description": "A boolean. Display initial matrix.", + "optional": true + }, + { + "id": "dof", + "name": "Dof", + "type": "Number", + "integer": true, + "value-key": "[DOF]", + "command-line-flag": "-dof", + "description": "An integer (int or long). Number of transform degrees of freedom.", + "optional": true + }, + { + "id": "echospacing", + "name": "Echospacing", + "type": "Number", + "value-key": "[ECHOSPACING]", + "command-line-flag": "-echospacing", + "description": "A float. Value of epi echo spacing - units of seconds.", + "optional": true + }, + { + "id": "fieldmap", + "name": "Fieldmap", + "type": "File", + "value-key": "[FIELDMAP]", + "command-line-flag": "-fieldmap", + "description": "A file name. Fieldmap image in rads/s - must be already registered to the reference image.", + "optional": true + }, + { + "id": "fieldmapmask", + "name": "Fieldmapmask", + "type": "File", + "value-key": "[FIELDMAPMASK]", + "command-line-flag": "-fieldmapmask", + "description": "A file name. Mask for fieldmap image.", + "optional": true + }, + { + "id": "fine_search", + "name": "Fine search", + "type": "Number", + "integer": true, + "value-key": "[FINE_SEARCH]", + "command-line-flag": "-finesearch", + "description": "An integer (int or long). Fine search delta angle.", + "optional": true + }, + { + "id": "force_scaling", + "name": "Force scaling", + "type": "Flag", + "value-key": "[FORCE_SCALING]", + "command-line-flag": "-forcescaling", + "description": "A boolean. Force rescaling even for low-res images.", + "optional": true + }, + { + "id": "in_file", + "name": "In file", + "type": "File", + "value-key": "[IN_FILE]", + "command-line-flag": "-in", + "description": "An existing file name. Input file.", + "optional": false + }, + { + "id": "in_matrix_file", + "name": "In matrix file", + "type": "File", + "value-key": "[IN_MATRIX_FILE]", + "command-line-flag": "-init", + "description": "A file name. Input 4x4 affine matrix.", + "optional": true + }, + { + "id": "in_weight", + "name": "In weight", + "type": "File", + "value-key": "[IN_WEIGHT]", + "command-line-flag": "-inweight", + "description": "An existing file name. File for input weighting volume.", + "optional": true + }, + { + "id": "interp", + "name": "Interp", + "type": "String", + "value-key": "[INTERP]", + "command-line-flag": "-interp", + "description": "'trilinear' or 'nearestneighbour' or 'sinc' or 'spline'. Final interpolation method used in reslicing.", + "optional": true, + "value-choices": [ + "trilinear", + "nearestneighbour", + "sinc", + "spline" + ] + }, + { + "id": "min_sampling", + "name": "Min sampling", + "type": "Number", + "value-key": "[MIN_SAMPLING]", + "command-line-flag": "-minsampling", + "description": "A float. Set minimum voxel dimension for sampling.", + "optional": true + }, + { + "id": "no_clamp", + "name": "No clamp", + "type": "Flag", + "value-key": "[NO_CLAMP]", + "command-line-flag": "-noclamp", + "description": "A boolean. Do not use intensity clamping.", + "optional": true + }, + { + "id": "no_resample", + "name": "No resample", + "type": "Flag", + "value-key": "[NO_RESAMPLE]", + "command-line-flag": "-noresample", + "description": "A boolean. Do not change input sampling.", + "optional": true + }, + { + "id": "no_resample_blur", + "name": "No resample blur", + "type": "Flag", + "value-key": "[NO_RESAMPLE_BLUR]", + "command-line-flag": "-noresampblur", + "description": "A boolean. Do not use blurring on downsampling.", + "optional": true + }, + { + "id": "no_search", + "name": "No search", + "type": "Flag", + "value-key": "[NO_SEARCH]", + "command-line-flag": "-nosearch", + "description": "A boolean. Set all angular searches to ranges 0 to 0.", + "optional": true + }, + { + "id": "padding_size", + "name": "Padding size", + "type": "Number", + "integer": true, + "value-key": "[PADDING_SIZE]", + "command-line-flag": "-paddingsize", + "description": "An integer (int or long). For applyxfm: interpolates outside image by size.", + "optional": true + }, + { + "id": "pedir", + "name": "Pedir", + "type": "Number", + "integer": true, + "value-key": "[PEDIR]", + "command-line-flag": "-pedir", + "description": "An integer (int or long). Phase encode direction of epi - 1/2/3=x/y/z & -1/-2/-3=-x/-y/-z.", + "optional": true + }, + { + "id": "ref_weight", + "name": "Ref weight", + "type": "File", + "value-key": "[REF_WEIGHT]", + "command-line-flag": "-refweight", + "description": "An existing file name. File for reference weighting volume.", + "optional": true + }, + { + "id": "reference", + "name": "Reference", + "type": "File", + "value-key": "[REFERENCE]", + "command-line-flag": "-ref", + "description": "An existing file name. Reference file.", + "optional": false + }, + { + "id": "rigid2D", + "name": "Rigid2d", + "type": "Flag", + "value-key": "[RIGID2D]", + "command-line-flag": "-2D", + "description": "A boolean. Use 2d rigid body mode - ignores dof.", + "optional": true + }, + { + "id": "save_log", + "name": "Save log", + "type": "Flag", + "value-key": "[SAVE_LOG]", + "command-line-flag": "--save_log", + "description": "A boolean. Save to log file.", + "optional": true + }, + { + "id": "schedule", + "name": "Schedule", + "type": "File", + "value-key": "[SCHEDULE]", + "command-line-flag": "-schedule", + "description": "An existing file name. Replaces default schedule.", + "optional": true + }, + { + "id": "searchr_x", + "name": "Searchr x", + "type": "Number", + "list": true, + "integer": true, + "min-list-entries": 2, + "max-list-entries": 2, + "value-key": "[SEARCHR_X]", + "command-line-flag": "-searchrx", + "description": "A list of from 2 to 2 items which are an integer (int or long). Search angles along x-axis, in degrees.", + "optional": true + }, + { + "id": "searchr_y", + "name": "Searchr y", + "type": "Number", + "list": true, + "integer": true, + "min-list-entries": 2, + "max-list-entries": 2, + "value-key": "[SEARCHR_Y]", + "command-line-flag": "-searchry", + "description": "A list of from 2 to 2 items which are an integer (int or long). Search angles along y-axis, in degrees.", + "optional": true + }, + { + "id": "searchr_z", + "name": "Searchr z", + "type": "Number", + "list": true, + "integer": true, + "min-list-entries": 2, + "max-list-entries": 2, + "value-key": "[SEARCHR_Z]", + "command-line-flag": "-searchrz", + "description": "A list of from 2 to 2 items which are an integer (int or long). Search angles along z-axis, in degrees.", + "optional": true + }, + { + "id": "sinc_width", + "name": "Sinc width", + "type": "Number", + "integer": true, + "value-key": "[SINC_WIDTH]", + "command-line-flag": "-sincwidth", + "description": "An integer (int or long). Full-width in voxels.", + "optional": true + }, + { + "id": "sinc_window", + "name": "Sinc window", + "type": "String", + "value-key": "[SINC_WINDOW]", + "command-line-flag": "-sincwindow", + "description": "'rectangular' or 'hanning' or 'blackman'. Sinc window.", + "optional": true, + "value-choices": [ + "rectangular", + "hanning", + "blackman" + ] + }, + { + "id": "uses_qform", + "name": "Uses qform", + "type": "Flag", + "value-key": "[USES_QFORM]", + "command-line-flag": "-usesqform", + "description": "A boolean. Initialize using sform or qform.", + "optional": true + }, + { + "id": "verbose", + "name": "Verbose", + "type": "Number", + "integer": true, + "value-key": "[VERBOSE]", + "command-line-flag": "-verbose", + "description": "An integer (int or long). Verbose mode, 0 is least.", + "optional": true + }, + { + "id": "wm_seg", + "name": "Wm seg", + "type": "File", + "value-key": "[WM_SEG]", + "command-line-flag": "-wmseg", + "description": "A file name. White matter segmentation volume needed by bbr cost function.", + "optional": true + }, + { + "id": "wmcoords", + "name": "Wmcoords", + "type": "File", + "value-key": "[WMCOORDS]", + "command-line-flag": "-wmcoords", + "description": "A file name. White matter boundary coordinates for bbr cost function.", + "optional": true + }, + { + "id": "wmnorms", + "name": "Wmnorms", + "type": "File", + "value-key": "[WMNORMS]", + "command-line-flag": "-wmnorms", + "description": "A file name. White matter boundary normals for bbr cost function.", + "optional": true + } + ], + "output-files": [ + { + "name": "Out file", + "id": "out_file", + "optional": true, + "description": "A file name. Registered output file.", + "path-template": "[IN_FILE]_flirt", + "value-key": "[OUT_FILE]", + "command-line-flag": "-out" + }, + { + "name": "Out log", + "id": "out_log", + "optional": true, + "description": "A file name. Output log.", + "path-template": "[IN_FILE]_flirt.log", + "value-key": "[OUT_LOG]" + }, + { + "name": "Out matrix file", + "id": "out_matrix_file", + "optional": true, + "description": "A file name. Output affine matrix in 4x4 asciii format.", + "path-template": "[IN_FILE]_flirt.mat", + "value-key": "[OUT_MATRIX_FILE]", + "command-line-flag": "-omat" + }, + { + "name": "Out file", + "id": "out_file", + "path-template": "out_file", + "optional": true, + "description": "An existing file name. Path/name of registered file (if generated)." + }, + { + "name": "Out log", + "id": "out_log", + "path-template": "out_log", + "optional": true, + "description": "A file name. Path/name of output log (if generated)." + }, + { + "name": "Out matrix file", + "id": "out_matrix_file", + "path-template": "out_matrix_file", + "optional": true, + "description": "An existing file name. Path/name of calculated affine transform (if generated)." + } + ], + "groups": [ + { + "id": "all_or_none_group", + "name": "All or none group", + "members": [ + "save_log", + "out_log" + ], + "all-or-none": true + }, + { + "id": "mutex_group", + "name": "Mutex group", + "members": [ + "apply_isoxfm", + "apply_xfm" + ], + "mutually-exclusive": true + } + ], + "tool-version": "1.0.0", + "schema-version": "0.5", + "container-image": { + "image": "mcin/docker-fsl:latest", + "type": "docker", + "index": "index.docker.io" + }, + "tags": { + "domain": "neuroinformatics", + "source": "nipype-interface" + } +} \ No newline at end of file diff --git a/nipype/utils/nipype2boutiques.py b/nipype/utils/nipype2boutiques.py index 21ecbc0eee..4f692a267b 100644 --- a/nipype/utils/nipype2boutiques.py +++ b/nipype/utils/nipype2boutiques.py @@ -3,37 +3,48 @@ absolute_import) from builtins import str, open, bytes -# 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. +# 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: -# * List outputs are not supported. -# * Default values are not extracted from the documentation of the Nipype interface. -# * The following input types must be ignored for the output path template creation (see option -t): -# ** String restrictions, i.e. String inputs that accept only a restricted set of values. -# ** mutually exclusive inputs. -# * Path-templates are wrong when output files are not created in the execution directory (e.g. when a sub-directory is created). -# * Optional outputs, i.e. outputs that not always produced, may not be detected. +# * 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 argparse import sys -import tempfile import simplejson as json from ..scripts.instance import import_module def generate_boutiques_descriptor( - module, interface_name, ignored_template_inputs, docker_image, - docker_index, verbose, ignore_template_numbers): + module, interface_name, container_image, container_type, + container_index=None, verbose=False, save=False, save_path=None, + author=None, ignore_inputs=None, tags=None): ''' - Returns a JSON string containing a JSON Boutiques description of a Nipype interface. + Returns a JSON string containing a JSON Boutiques description of a + Nipype interface. Arguments: * module: module where the Nipype interface is declared. - * interface: Nipype interface. - * ignored_template_inputs: a list of input names that should be ignored in the generation of output path templates. - * ignore_template_numbers: True if numbers must be ignored in output path creations. + * 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) ''' if not module: @@ -55,119 +66,323 @@ def generate_boutiques_descriptor( tool_desc = {} tool_desc['name'] = interface_name tool_desc[ - 'command-line'] = "nipype_cmd " + module_name + " " + interface_name + " " - tool_desc[ - 'description'] = interface_name + ", as implemented in Nipype (module: " + module_name + ", interface: " + interface_name + ")." + '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['outputs'] = [] - tool_desc['tool-version'] = interface.version - tool_desc['schema-version'] = '0.2-snapshot' - if docker_image: - tool_desc['docker-image'] = docker_image - if docker_index: - tool_desc['docker-index'] = docker_index + 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()): - input = get_boutiques_input(inputs, interface, name, spec, - ignored_template_inputs, verbose, - ignore_template_numbers) - tool_desc['inputs'].append(input) - tool_desc['command-line'] += input['command-line-key'] + " " - if verbose: - print("-> Adding input " + input['name']) + # 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(name, interface, tool_desc['inputs'], - verbose) - if output['path-template'] != "": - tool_desc['outputs'].append(output) + 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']) - elif verbose: - print("xx Skipping output " + output['name'] + - " with no path template.") - if tool_desc['outputs'] == []: + 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.") - # Removes all temporary values from inputs (otherwise they will - # appear in the JSON output) - for input in tool_desc['inputs']: - del input['tempvalue'] - return json.dumps(tool_desc, indent=4, separators=(',', ': ')) - - -def get_boutiques_input(inputs, interface, input_name, spec, - ignored_template_inputs, verbose, - ignore_template_numbers): +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 intput. + 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. - * ignored_template_inputs: input names for which no temporary value must be generated. - * ignore_template_numbers: True if numbers must be ignored in output path creations. + * 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. """ - if not spec.desc: - spec.desc = "No description provided." - spec_info = spec.full_info(inputs, input_name, None) - - input = {} - input['id'] = input_name - input['name'] = input_name.replace('_', ' ').capitalize() - input['type'] = get_type_from_spec_info(spec_info) - input['list'] = is_list(spec_info) - input['command-line-key'] = "[" + input_name.upper( + 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 - input['command-line-flag'] = ("--%s" % input_name + " ").strip() - input['tempvalue'] = None - input['description'] = spec_info.capitalize( - ) + ". " + spec.desc.capitalize() - if not input['description'].endswith('.'): - input['description'] += '.' + + 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): - input['optional'] = True + inp['optional'] = True else: - input['optional'] = False + inp['optional'] = False if spec.usedefault: - input['default-value'] = spec.default_value()[1] - - # Create unique, temporary value. - temp_value = must_generate_value(input_name, input['type'], - ignored_template_inputs, spec_info, spec, - ignore_template_numbers) - if temp_value: - tempvalue = get_unique_value(input['type'], input_name) - setattr(interface.inputs, input_name, tempvalue) - input['tempvalue'] = tempvalue - if verbose: - print("oo Path-template creation using " + input['id'] + "=" + - str(tempvalue)) - - # Now that temp values have been generated, set Boolean types to - # Number (there is no Boolean type in Boutiques) - if input['type'] == "Boolean": - input['type'] = "Number" + 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 input + return inp -def get_boutiques_output(name, interface, tool_inputs, verbose=False): +def get_boutiques_output(outputs, name, spec, interface, tool_inputs): """ - Returns a dictionary containing the Boutiques output corresponding to a Nipype output. + 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). + * tool_inputs: list of tool inputs (as produced by method + get_boutiques_input). Assumes that: * Output names are unique. @@ -177,116 +392,223 @@ def get_boutiques_output(name, interface, tool_inputs, verbose=False): """ output = {} output['name'] = name.replace('_', ' ').capitalize() - output['id'] = name - output['type'] = "File" + + # 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'] = "" - output[ - 'optional'] = True # no real way to determine if an output is always produced, regardless of the input values. + + # 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. - output_value = interface._list_outputs()[name] - if output_value != "" and isinstance( - output_value, - str): # FIXME: this crashes when there are multiple output values. - # Go find from which input value it was built - for input in tool_inputs: - if not input['tempvalue']: - continue - input_value = input['tempvalue'] - if input['type'] == "File": - # Take the base name - input_value = os.path.splitext( - os.path.basename(input_value))[0] - if str(input_value) in output_value: - output_value = os.path.basename( - output_value.replace(input_value, - input['command-line-key']) - ) # FIXME: this only works if output is written in the current directory - output['path-template'] = os.path.basename(output_value) + 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_type_from_spec_info(spec_info): +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): ''' - Returns an input type from the spec info. There must be a better - way to get an input type in Nipype than to parse the spec info. + Generates a description based on the input or output spec. ''' - if ("an existing file name" in spec_info) or ( - "input volumes" in spec_info): - return "File" - elif ("an integer" in spec_info or "a float" in spec_info): - return "Number" - elif "a boolean" in spec_info: - return "Boolean" - return "String" + 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", '') -def is_list(spec_info): + if not boutiques_description.endswith('.'): + boutiques_description += '.' + + return boutiques_description + + +def fill_in_missing_output_path(output, output_name, tool_inputs): ''' - Returns True if the spec info looks like it describes a list - parameter. There must be a better way in Nipype to check if an input - is a list. + Creates a path template for outputs that are missing one + This is needed for the descriptor to be valid (path template is required) ''' - if "a list" in spec_info: - return True - return False + # 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 get_unique_value(type, id): +def generate_custom_inputs(desc_inputs): ''' - Returns a unique value of type 'type', for input with id 'id', - assuming id is unique. + 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. ''' - return { - "File": os.path.abspath(create_tempfile()), - "Boolean": True, - "Number": abs(hash(id)), # abs in case input param must be positive... - "String": id - }[type] + 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 create_tempfile(): +def reorder_cmd_line_args(cmd_line, interface, ignore_inputs=None): ''' - Creates a temp file and returns its name. + Generates a new command line with the positional arguments in the + correct order ''' - fileTemp = tempfile.NamedTemporaryFile(delete=False) - fileTemp.write(b"hello") - fileTemp.close() - return fileTemp.name + interface_name = cmd_line.split()[0] + positional_arg_dict = {} + positional_args = [] + non_positional_args = [] - -def must_generate_value(name, type, ignored_template_inputs, spec_info, spec, - ignore_template_numbers): + 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): ''' - Return True if a temporary value must be generated for this input. - Arguments: - * name: input name. - * type: input_type. - * ignored_template_inputs: a list of inputs names for which no value must be generated. - * spec_info: spec info of the Nipype input - * ignore_template_numbers: True if numbers must be ignored. + Generates the command line flag for a given input ''' - # Return false when type is number and numbers must be ignored. - if ignore_template_numbers and type == "Number": - return False - # Only generate value for the first element of mutually exclusive inputs. - if spec.xor and spec.xor[0] != name: - return False - # Directory types are not supported - if "an existing directory name" in spec_info: - return False - # Don't know how to generate a list. - if "a list" in spec_info or "a tuple" in spec_info: - return False - # Don't know how to generate a dictionary. - if "a dictionary" in spec_info: - return False - # Best guess to detect string restrictions... - if "' or '" in spec_info: - return False - if spec.default or spec.default_value(): - return False - if not ignored_template_inputs: - return True - return not (name in ignored_template_inputs) + 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 diff --git a/nipype/utils/tests/test_nipype2boutiques.py b/nipype/utils/tests/test_nipype2boutiques.py index f1d0c46eed..19735df6b5 100644 --- a/nipype/utils/tests/test_nipype2boutiques.py +++ b/nipype/utils/tests/test_nipype2boutiques.py @@ -2,16 +2,45 @@ # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: from future import standard_library -standard_library.install_aliases() - from ..nipype2boutiques import generate_boutiques_descriptor +from nipype.testing import example_data +import json +standard_library.install_aliases() def test_generate(): - generate_boutiques_descriptor(module='nipype.interfaces.ants.registration', - interface_name='ANTS', - ignored_template_inputs=(), - docker_image=None, - docker_index=None, - verbose=False, - ignore_template_numbers=False) + ignored_inputs = [ + "args", + "environ", + "output_type" + ] + desc = generate_boutiques_descriptor(module='nipype.interfaces.fsl', + interface_name='FLIRT', + container_image=('mcin/' + 'docker-fsl:latest'), + container_index='index.docker.io', + container_type='docker', + verbose=False, + save=False, + ignore_inputs=ignored_inputs, + author=("Oxford Centre for Functional" + " MRI of the Brain (FMRIB)")) + + with open(example_data('nipype2boutiques_example.json'), 'r') as desc_file: + # Make sure that output descriptor matches the expected descriptor. + output_desc = json.loads(desc) + expected_desc = json.load(desc_file) + assert (output_desc.get('name') == + expected_desc.get('name')) + assert (output_desc.get('author') == + expected_desc.get('author')) + assert (output_desc.get('command-line') == + expected_desc.get('command-line')) + assert (output_desc.get('description') == + expected_desc.get('description')) + assert (len(output_desc.get('inputs')) == + len(expected_desc.get('inputs'))) + assert(len(output_desc.get('output-files')) == + len(expected_desc.get('output-files'))) + assert (output_desc.get('container-image').get('image') == + expected_desc.get('container-image').get('image'))