def _parse_stdout(self, stdout): import re import os files = [] reoriented_files = [] reoriented_and_cropped_files = [] bvecs = [] bvals = [] skip = False last_added_file = None for line in stdout.split("\n"): if not skip: file = None if line.startswith("Saving "): file = line[len("Saving "):] elif line.startswith("GZip..."): #for gzipped outpus files are not absolute if isdefined(self.inputs.output_dir): output_dir = self.inputs.output_dir print output_dir else: output_dir = self._gen_filename('output_dir') file = os.path.abspath(os.path.join(output_dir, line[len("GZip..."):])) elif line.startswith("Number of diffusion directions "): if last_added_file: base, filename, ext = split_filename(last_added_file) bvecs.append(os.path.join(base,filename + ".bvec")) bvals.append(os.path.join(base,filename + ".bval")) elif re.search('.*->(.*)', line): val = re.search('.*->(.*)', line) val = val.groups()[0] if isdefined(self.inputs.output_dir): output_dir = self.inputs.output_dir else: output_dir = self._gen_filename('output_dir') val = os.path.join(output_dir, val) file = val if file: if last_added_file and os.path.exists(file) and not last_added_file in file: files.append(file) last_added_file = file continue if line.startswith("Reorienting as "): reoriented_files.append(line[len("Reorienting as "):]) skip = True continue elif line.startswith("Cropping NIfTI/Analyze image "): base, filename = os.path.split(line[len("Cropping NIfTI/Analyze image "):]) filename = "c" + filename reoriented_and_cropped_files.append(os.path.join(base, filename)) skip = True continue skip = False return files, reoriented_files, reoriented_and_cropped_files, bvecs, bvals
def _run_interface(self, runtime): motion_mat = np.loadtxt(self.inputs.motion_mat) structural_image = self.inputs.structural_image if isdefined(self.inputs.structural2ref_regmat): structural2ref_regmat = np.loadtxt( self.inputs.structural2ref_regmat) pet2ref_mat = np.loadtxt(self.inputs.pet2ref_mat) pet_image = self.inputs.pet_image if isdefined(self.inputs.corr_factor): corr_factor = self.inputs.corr_factor else: corr_factor = 1 ref2pet_mat = np.linalg.inv(pet2ref_mat) if structural_image: ref2pet_mat = np.linalg.inv(structural2ref_regmat) out_basename = 'al2Struct' else: out_basename = 'al2Ref' basename = pet_image.split('/')[-1].split('.')[0] outname = '{0}_{1}'.format(basename, out_basename) motion_mat_inv = np.linalg.inv(motion_mat) transformation_mat = np.dot(ref2pet_mat, np.dot(motion_mat_inv, pet2ref_mat)) np.savetxt('transformation.mat', transformation_mat) if structural_image: self.applyxfm(pet_image, structural_image, 'transformation.mat', outname + '_mc') else: self.applyxfm(pet_image, pet_image, 'transformation.mat', outname + '_mc') self.apply_temporal_correction(outname + '_mc', corr_factor, outname + '_mc_corr') self.apply_temporal_correction(pet_image, corr_factor, outname + '_no_mc_corr') self.out_basename = out_basename return runtime
def get_host_path(self, path): """ Takes a file path that is valid in the container and converts it to one valid on the host. """ if not path: return if isdefined(self.inputs.map_dirs_list): mounts = self.inputs.map_dirs_list for mount in mounts: h_path, c_path = mount.split(':') h_path = os.path.abspath(h_path) c_path = os.path.abspath(c_path) if path.startswith(c_path): rel_path = os.path.relpath(path, c_path) path = os.path.join(h_path, rel_path) return (path)
def test_bids_infields_outfields(tmpdir): tmpdir.chdir() infields = ['infield1', 'infield2'] outfields = ['outfield1', 'outfield2'] bg = nio.BIDSDataGrabber(infields=infields) for outfield in outfields: bg.inputs.output_query[outfield] = {'key': 'value'} for infield in infields: assert (infield in bg.inputs.traits()) assert (not (isdefined(bg.inputs.get()[infield]))) for outfield in outfields: assert (outfield in bg._outputs().traits()) # now try without defining outfields, we should get anat and func for free bg = nio.BIDSDataGrabber() for outfield in ['anat', 'func']: assert outfield in bg._outputs().traits()
def test_bids_infields_outfields(tmpdir): tmpdir.chdir() infields = ['infield1', 'infield2'] outfields = ['outfield1', 'outfield2'] bg = nio.BIDSDataGrabber(infields=infields) for outfield in outfields: bg.inputs.output_query[outfield] = {'key': 'value'} for infield in infields: assert(infield in bg.inputs.traits()) assert(not(isdefined(bg.inputs.get()[infield]))) for outfield in outfields: assert(outfield in bg._outputs().traits()) # now try without defining outfields, we should get anat and func for free bg = nio.BIDSDataGrabber() for outfield in ['anat', 'func']: assert outfield in bg._outputs().traits()
def test_bids_infields_outfields(tmpdir): tmpdir.chdir() infields = ["infield1", "infield2"] outfields = ["outfield1", "outfield2"] bg = nio.BIDSDataGrabber(infields=infields) for outfield in outfields: bg.inputs.output_query[outfield] = {"key": "value"} for infield in infields: assert infield in bg.inputs.traits() assert not (isdefined(bg.inputs.get()[infield])) for outfield in outfields: assert outfield in bg._outputs().traits() # now try without defining outfields bg = nio.BIDSDataGrabber() for outfield in ["T1w", "bold"]: assert outfield in bg._outputs().traits()
def _run_interface(self, runtime): dct = {} pet_data = sorted(glob.glob(self.inputs.pet_data + '/*.nii.gz')) motion_mats = sorted(glob.glob(self.inputs.motion_mats + '/*.txt')) reference = self.inputs.reference if isdefined(self.inputs.corr_factors): corr_factors = np.loadtxt(self.inputs.corr_factors).tolist() else: corr_factors = None if not pet_data: raise Exception('No images found in {}!'.format( self.inputs.pet_cropped)) elif (pet_data and (len(pet_data) != len(motion_mats))): raise Exception("The number of the PET images found in {0} is " "different from that of the motion matrices found " "in {1}. Please check.".format( self.inputs.pet_data, self.inputs.motion_mats)) else: pet_qform = self.get_qform(pet_data[0]) ref_qform = self.get_qform(reference) ref_qform_inv = np.linalg.inv(ref_qform) pet2ref = np.dot(ref_qform_inv, pet_qform) np.savetxt('pet2ref.mat', pet2ref) dct['pet_data'] = pet_data dct['motion_mats'] = motion_mats if (corr_factors is not None and (len(pet_data) == len(corr_factors))): dct['corr_factors'] = corr_factors elif corr_factors is None: dct['corr_factors'] = [] if (corr_factors is not None and (len(pet_data) != len(corr_factors))): raise Exception( "The number of the PET images found in {0} is {1} and it " "is different from that of the PET correction factors" "which is {2}. Please check.".format( self.inputs.pet_data, len(pet_data), len(corr_factors))) self.dct = dct return runtime
def get_container_path(self, path, mounts): """ Takes a file path that is valid in the host and a list of mounts ['host:container'] changes the file path to the path in the container. """ if not path: return if isdefined(mounts): for mount in mounts: h_path, c_path = mount.split(':') h_path = os.path.abspath(h_path) c_path = os.path.abspath(c_path) if path.startswith(h_path): print(path) rel_path = os.path.relpath(path, h_path) path = os.path.join(c_path, rel_path) print(path) print("") return (path)
def _connect(hierarchy): wf = hierarchy[-1] outputnode: Optional[pe.Node] = wf.get_node("outputnode") if outputnode is not None: outputattrs = set(outputnode.outputs.copyable_trait_names()) attrs = (inputattrs & outputattrs ) - connected_attrs # find common attr names actually_connected_attrs = set() for _, _, datadict in wf._graph.in_edges(outputnode, data=True): _, infields = zip(*datadict.get("connect", [])) actually_connected_attrs.update(infields) for key, value in outputnode.inputs.get().items(): if isdefined(value): actually_connected_attrs.add(key) attrs &= actually_connected_attrs for attr in attrs: self.connect_attr(hierarchy, outputnode, attr, nodehierarchy, node, attr) connected_attrs.add(attr) for attr in list(dsattrs): childtpl = _find_child(hierarchy, attr) if childtpl is not None: childhierarchy, childnode = childtpl childhierarchy, childnode, childattr = _find_input( childhierarchy, childnode, "in_file") self.connect_attr(childhierarchy, childnode, childattr, nodehierarchy, node, attr) dsattrs.remove(attr) connected_attrs.add(attr)
def _parse_inputs(self, skip=None): # increment the position argument for all child trait objects # ensure that container arguments and commands come first trait_names = self.inputs.editable_traits() local_trait_names = DockerInputSpec().editable_traits() # find the current maximum position max_position = 0 for name in local_trait_names: t = DockerInputSpec().trait(name) if t.position and t.position > max_position: max_position = t.position # update the passed parameter positions new_names = set(trait_names) - set(local_trait_names) for name in new_names: t = self.inputs.trait(name) if t.position and t.position >= 0: t.position = t.position + max_position # parse any map_dirs_tuples directives, covert them to list strings if isdefined(self.inputs.map_dirs_tuples): map_strs = [":".join(str) for str in self.inputs.map_dirs_tuples] if not isdefined(self.inputs.map_dirs_list): self.inputs.map_dirs_list = [] [self.inputs.map_dirs_list.append(str) for str in map_strs] # Mount docker /outputs to host cwd if isdefined(self.inputs.map_dirs_tuples): self.inputs.map_dirs_list.append(str(os.getcwd()) + ":/outputs") else: self.inputs.map_dirs_list = [str(os.getcwd()) + ":/outputs"] # original parse code here all_args = [] initial_args = {} final_args = {} metadata = dict(argstr=lambda t: t is not None) #Automatic mount of host files and folders for name, spec in sorted(self.inputs.traits(**metadata).items()): value = getattr(self.inputs, name) if spec.is_trait_type(DockerFile): h_dir, file = os.path.split(value) self.inputs.map_dirs_list.append(h_dir + ":" + "/mounts/" + name + "/") self.inputs.map_dirs_list = list(set( self.inputs.map_dirs_list)) elif spec.is_trait_type(DockerDir): self.inputs.map_dirs_list.append(value + ":" + "/mounts/" + name + "/") for name, spec in sorted(self.inputs.traits(**metadata).items()): if skip and name in skip: continue value = getattr(self.inputs, name) if spec.name_source: value = self._filename_from_source(name) elif spec.genfile: if not isdefined(value) or value is None: value = self._gen_filename(name) if not isdefined(value): continue # modify DockerFile paths if spec.is_trait_type((DockerFile, DockerDir)): value = self.get_container_path(value, self.inputs.map_dirs_list) # Add custom parsing for map_dir_list if name is 'map_dirs_list': arg = "".join(["-v " + str(x) + " " for x in value]) else: arg = self._format_arg(name, spec, value) if arg is None: continue pos = spec.position if pos is not None: if int(pos) >= 0: initial_args[pos] = arg else: final_args[pos] = arg else: all_args.append(arg) first_args = [arg for pos, arg in sorted(initial_args.items())] last_args = [arg for pos, arg in sorted(final_args.items())] return first_args + all_args + last_args
class NiiWrangler(BaseInterface): input_spec = NiiWranglerInputSpec output_spec = NiiWranglerOutputSpec def __init__(self, *args, **kwargs): super(NiiWrangler, self).__init__(*args, **kwargs) self.t1_files = [] self.rsfmri_files = [] self.dwi_files = [] self.dwi_ap_files = [] self.dwi_pa_files = [] self.flair_files = [] self.fieldmap_mag = [] self.fieldmap_ph = [] self.bval = [] self.bvec = [] self.ep_TR= None self.fieldmap_mag_delta_te = "NONE" self.t1_sample_spacing = 0. self.ep_dwi_echo_spacings = None self.ep_rsfmri_echo_spacings = None self.ep_unwarp_dirs = None def _run_interface(self, runtime): import re import operator print "starting NII wrangler" nii_files = self.inputs.nii_files smap = self.inputs.series_map dinfo = self.inputs.dicom_info #block_averaging = self.inputs.block_struct_averaging s_num_reg = re.compile(".*s(\d+)a(?!.*/)") # sux to use filename. make more robust if needed. nii_by_series = {} fails = [] extras = [] for fn in nii_files: try: # we only want the first nii for each series # TODO: find out what those others (A/B) are all about. fix this as needed. sn = int(s_num_reg.match(fn).groups()[0]) if sn in nii_by_series: extras.append(fn) continue nii_by_series[sn] = fn except Exception, e: fails.append(fn) if fails: raise ValueError("Could not derive series number from file names: %s." % str(fails)) if extras: print >> sys.stderr, "\nWARNING: Ignoring extra niftis: %s\n" % str(extras) # add nifti names to the dicts m_count = 0 for sn, fn in nii_by_series.iteritems(): m = filter(lambda x: x.get("series_num",-1) == sn, dinfo) if not m: continue m_count += 1 m[0]["nifti_file"] = fn if not m_count == len(dinfo): raise ValueError("incorrect number of nifti->series matches (%d/%d)" % (m_count, len(dinfo))) # time for some data wrangling nf = "nifti_file" sd = "series_desc" it = "image_type" t1fs = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("t1",[]), dinfo) if nf in d] #if block_averaging: # t1fs = [t1fs[0]] # t2fs = [t2fs[0]] self.t1_files = [d[nf] for d in t1fs] #get rsfmri, if no "resting state return default file" bs = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("rsfmri",[]), dinfo) if nf in d] self.rsfmri_files = [d[nf] for d in bs] #get dwi dwi = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("dwi",[]), dinfo) if nf in d] if len(dwi)==0: print "no DTI acquired" self.dwi_files=['nothing to proceed'] self.dwi_pa_files=['nothing to proceed'] self.dwi_ap_files=['nothing to proceed'] else: self.dwi_files = [d[nf] for d in dwi] dwi_pa = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("fieldmap_pa",[]), dinfo) if nf in d] self.dwi_pa_files = [d[nf] for d in dwi_pa] dwi_ap = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("fieldmap_ap",[]), dinfo) if nf in d] self.dwi_ap_files = [d[nf] for d in dwi_ap] flair = [d for d in filter(lambda x: sd in x and x[sd] in smap.get("flair",[]), dinfo) if nf in d] if len(flair)==0: print "no FLAIR acquired" self.flair_files=['nothing to proceed'] else: self.flair_files = [d[nf] for d in flair] # we have to do some extra looking at the headers for the mag and phase fieldmaps too mag_fs = filter(lambda x: sd in x and x[sd] in smap.get("fieldmap_magnitude",[]) and it in x and isinstance(x[it], list) and len(x[it]) > 2 and x[it][2].strip().lower() == "m", dinfo) # we want the 3rd field of image type to be 'm' phase_fs = filter(lambda x: sd in x and x[sd] in smap.get("fieldmap_phase",[]) and it in x and isinstance(x[it], list) and len(x[it]) > 2 and x[it][2].strip().lower() == "p", dinfo) # we want the 3rd field of image type to be 'p' self.fieldmap_mag = [d[nf] for d in mag_fs if nf in d] self.fieldmap_ph = [d[nf] for d in phase_fs if nf in d] # calculate echo spacing for rsfmri: #if more than one resting state scan was acquired: ep_rsfmri_echo_fail = False ep_TR_fail=False if len(bs)>1: print "two or more resting-state scans" if isdefined(self.inputs.ep_rsfmri_echo_spacings): self.ep_rsfmri_echo_spacings = [self.inputs.ep_rsfmri_echo_spacings for n in self.rsfmri_files] elif bs and any(["bw_per_pix_phase_encode" in d and "acq_matrix_n" in d for d in bs]): if not all(["bw_per_pix_phase_encode" in d and "acq_matrix_n" in d for d in bs]): ep_rsfmri_echo_fail = True else: self.ep_rsfmri_echo_spacings = [1/(d["bw_per_pix_phase_encode"] * d["acq_matrix_n"]) for d in bs] else: self.ep_rsfmri_echo_spacings = ["NONE" for n in self.rsfmri_files] if isdefined(self.inputs.ep_TR): self.ep_TR=[self.inputs.ep_TR for n in self.rsfmri_files] elif bs and any(["TR" in d for d in bs]): if not all(["TR" in d for d in bs]): ep_TR_fail = True else: self.ep_TR=[d["TR"] for d in bs] else: self.ep_TR= ["NONE" for n in self.rsfmri_files] else: print "one resting-state scan" if isdefined(self.inputs.ep_rsfmri_echo_spacings): self.ep_rsfmri_echo_spacings = self.inputs.ep_rsfmri_echo_spacings elif bs and "bw_per_pix_phase_encode" in bs[0] and "acq_matrix_n" in bs[0]: if not "bw_per_pix_phase_encode" in bs[0] and "acq_matrix_n" in bs[0]: ep_rsfmri_echo_fail = True else: self.ep_rsfmri_echo_spacings = 1/(bs[0]["bw_per_pix_phase_encode"] * bs[0]["acq_matrix_n"]) else: self.ep_rsfmri_echo_spacings = "NONE" if isdefined(self.inputs.ep_TR): self.ep_TR=self.inputs.ep_TR elif bs and "TR" in bs[0]: if not "TR" in bs[0]: ep_TR_fail = True else: self.ep_TR=bs[0]["TR"] else: self.ep_TR= "NONE" ep_dwi_echo_fail = False if len(dwi)>1: print "more than one dwi scan" if isdefined(self.inputs.ep_dwi_echo_spacings): self.ep_dwi_echo_spacings = [self.inputs.ep_dwi_echo_spacings for n in self.dwi_files] elif bs and any(["bw_per_pix_phase_encode" in d and "acq_matrix_n" in d for d in dwi]): if not all(["bw_per_pix_phase_encode" in d and "acq_matrix_n" in d for d in dwi]): ep_dwi_echo_fail = True else: self.ep_dwi_echo_spacings = [1/(d["bw_per_pix_phase_encode"] * d["acq_matrix_n"]) for d in dwi] else: self.ep_dwi_echo_spacings = ["NONE" for n in self.dwi_files] else: print "one dwi scan" if isdefined(self.inputs.ep_dwi_echo_spacings): self.ep_dwi_echo_spacings = self.inputs.ep_dwi_echo_spacings elif bs and "bw_per_pix_phase_encode" in bs[0] and "acq_matrix_n" in bs[0]: if not "bw_per_pix_phase_encode" in bs[0] and "acq_matrix_n" in bs[0]: ep_dwi_echo_fail = True else: self.ep_dwi_echo_spacings = 1/(bs[0]["bw_per_pix_phase_encode"] * bs[0]["acq_matrix_n"]) else: self.ep_dwi_echo_spacings = "NONE" # output delta te for magnitude fieldmap if available if mag_fs and self.fieldmap_mag and self.fieldmap_ph: self.fieldmap_mag_delta_te = mag_fs[0].get("delta_te","NONE") else: self.fieldmap_mag_delta_te = "NONE" # # a BOLD image by any other name... # self.bold_names = ["bold_%d" % n for n in xrange(len(self.bolds))] # # we'll derive the ep unwarp dir in the future... for now, just set it in config if isdefined(self.inputs.ep_unwarp_dir): self.ep_unwarp_dirs = [self.inputs.ep_unwarp_dir for n in self.rsfmri_files] # if you have any polarity swapped series, apply that now pswaps = smap.get("polarity_swapped", []) if pswaps: for b_idx, uw_dir in enumerate(self.ep_unwarp_dirs): if bs[b_idx].get("series_desc",None) in pswaps: raw_dir = uw_dir.replace("-","") self.ep_unwarp_dirs[b_idx] = "-"+raw_dir if not "-" in uw_dir else raw_dir else: # fail. we can do better! raise ValueError("We can't derive ep_unwarp_dir yet. Please set it in the nii wrangler config section.") ### and let's do some sanity checking # warn if you're going to ignore field or magnitude phase maps if not (len(self.fieldmap_mag) and len(self.fieldmap_ph)): print >> sys.stderr, "\nWARNING: found %d magnitude fieldmaps and %d phase fieldmaps.\n" % (len(self.fieldmap_mag), len(self.fieldmap_ph)) ### derive the derived values # t1_sample_spacing if t1fs and "RealDwellTime" in t1fs[0].keys(): self.t1_sample_spacing = t1fs[0]["RealDwellTime"] * math.pow(10,-9) else: self.t1_sample_spacing = "NONE" # don't continue if there was screwiness around calculating ep echo spacing if ep_rsfmri_echo_fail: raise ValueError("Unabel to calculate fmri ep echo spacing. Try specifying manually in nii wrangler config section.") if ep_dwi_echo_fail: raise ValueError("Unabel to calculate dwi ep echo spacing. Try specifying manually in nii wrangler config section.") if ep_TR_fail: raise ValueError("Unabel to derive TR. Try specifying manually in nii wrangler config section.") return runtime
def connect(self, nodehierarchy, node, source_file=None, subject_id=None, **_): """ connect equally names attrs preferentially use datasinked outputs """ connected_attrs = set() inputattrs = set(node.inputs.copyable_trait_names()) dsattrs = set(attr for attr in inputattrs if attr.startswith("ds_")) for key, value in node.inputs.get().items(): if isdefined(value): inputattrs.remove(key) ignore = frozenset( ["alt_bold_mask_std", "alt_bold_std", "alt_spatial_reference"]) inputattrs -= ignore def _connect(hierarchy): wf = hierarchy[-1] outputnode: Optional[pe.Node] = wf.get_node("outputnode") if outputnode is not None: outputattrs = set(outputnode.outputs.copyable_trait_names()) attrs = (inputattrs & outputattrs ) - connected_attrs # find common attr names actually_connected_attrs = set() for _, _, datadict in wf._graph.in_edges(outputnode, data=True): _, infields = zip(*datadict.get("connect", [])) actually_connected_attrs.update(infields) for key, value in outputnode.inputs.get().items(): if isdefined(value): actually_connected_attrs.add(key) attrs &= actually_connected_attrs for attr in attrs: self.connect_attr(hierarchy, outputnode, attr, nodehierarchy, node, attr) connected_attrs.add(attr) for attr in list(dsattrs): childtpl = _find_child(hierarchy, attr) if childtpl is not None: childhierarchy, childnode = childtpl childhierarchy, childnode, childattr = _find_input( childhierarchy, childnode, "in_file") self.connect_attr(childhierarchy, childnode, childattr, nodehierarchy, node, attr) dsattrs.remove(attr) connected_attrs.add(attr) hierarchy = self._get_hierarchy("fmriprep_wf", source_file=source_file, subject_id=subject_id) wf = hierarchy[-1] # anat only anat_wf = wf.get_node("anat_preproc_wf") if anat_wf is None: # func first _connect(hierarchy) if "skip_vols" in inputattrs: initial_boldref_wf = wf.get_node("initial_boldref_wf") assert isinstance(initial_boldref_wf, pe.Workflow) outputnode = initial_boldref_wf.get_node("outputnode") self.connect_attr( [*hierarchy, initial_boldref_wf], outputnode, "skip_vols", nodehierarchy, node, "skip_vols", ) connected_attrs.add("skip_vols") for name in [ "bold_bold_trans_wf", "bold_hmc_wf", "final_boldref_wf", "bold_reg_wf", "sdc_estimate_wf", "sdc_bypass_wf", "sdc_unwarp_report_wf", "bold_std_trans_wf", "bold_surf_wf", "bold_confounds_wf", ]: bold_wf = wf.get_node(name) if bold_wf is not None: _connect([*hierarchy, bold_wf]) if "bold_split" in inputattrs: splitnode = wf.get_node("split_opt_comb") if splitnode is None: splitnode = wf.get_node("bold_split") self.connect_attr(hierarchy, splitnode, "out_files", nodehierarchy, node, "bold_split") connected_attrs.add("bold_split") report_hierarchy = self._get_hierarchy("reports_wf", source_file=source_file, subject_id=subject_id) func_report_wf = report_hierarchy[-1].get_node( "func_report_wf") # this is not part of fmriprep if func_report_wf is not None: _connect([*report_hierarchy, func_report_wf]) while wf.get_node("anat_preproc_wf") is None: hierarchy.pop() wf = hierarchy[-1] anat_wf = wf.get_node("anat_preproc_wf") assert isinstance(anat_wf, pe.Workflow) for name in ["anat_norm_wf", "anat_reports_wf"]: wf = anat_wf.get_node(name) _connect([*hierarchy, anat_wf, wf]) _connect([*hierarchy, anat_wf]) if connected_attrs != inputattrs: missing_attrs = sorted(inputattrs - connected_attrs) logger.info( f"Unable to find fMRIPrep outputs {p.join(missing_attrs)} " f"for workflow {nodehierarchy}") return