diff --git a/dcm2bids/dcm2bids.py b/dcm2bids/dcm2bids.py index 6b842e07..ea305c2b 100644 --- a/dcm2bids/dcm2bids.py +++ b/dcm2bids/dcm2bids.py @@ -1,20 +1,25 @@ # -*- coding: utf-8 -*- -"""dcm2bids module""" +""" +Reorganising NIfTI files from dcm2niix into the Brain Imaging Data Structure +""" import argparse import logging import os +from pathlib import Path import platform import sys from datetime import datetime from glob import glob -from .dcm2niix import Dcm2niix -from .logger import setup_logging -from .sidecar import Sidecar, SidecarPairing -from .structure import Participant -from .utils import DEFAULT, load_json, save_json, run_shell_command, splitext_ -from .version import __version__, check_latest, dcm2niix_version + +from dcm2bids.dcm2niix import Dcm2niix +from dcm2bids.logger import setup_logging +from dcm2bids.sidecar import Sidecar, SidecarPairing +from dcm2bids.structure import Participant +from dcm2bids.utils import (DEFAULT, load_json, save_json, + splitext_, run_shell_command, valid_path) +from dcm2bids.version import __version__, check_latest, dcm2niix_version class Dcm2bids(object): @@ -47,8 +52,8 @@ def __init__( self._dicomDirs = [] self.dicomDirs = dicom_dir - self.bidsDir = output_dir - self.config = load_json(config) + self.bidsDir = valid_path(output_dir, type="folder") + self.config = load_json(valid_path(config, type="file")) self.participant = Participant(participant, session) self.clobber = clobber self.forceDcm2niix = forceDcm2niix @@ -74,37 +79,18 @@ def dicomDirs(self): @dicomDirs.setter def dicomDirs(self, value): - if isinstance(value, list): - dicom_dirs = value - else: - dicom_dirs = [value] - - dir_not_found = [] - for _dir in dicom_dirs: - if os.path.isdir(_dir): - pass - else: - dir_not_found.append(_dir) - if dir_not_found: - raise FileNotFoundError(dir_not_found) + dicom_dirs = value if isinstance(value, list) else [value] - self._dicomDirs = dicom_dirs + valid_dirs = [valid_path(_dir, "folder") for _dir in dicom_dirs] + + self._dicomDirs = valid_dirs def set_logger(self): """ Set a basic logger""" - logDir = os.path.join(self.bidsDir, DEFAULT.tmpDirName, "log") - logFile = os.path.join( - logDir, - "{}_{}.log".format( - self.participant.prefix, datetime.now().isoformat().replace(":", "") - ), - ) - - # os.makedirs(logdir, exist_ok=True) - # python2 compatibility - if not os.path.exists(logDir): - os.makedirs(logDir) + logDir = self.bidsDir / DEFAULT.tmpDirName / "log" + logFile = logDir / f"{self.participant.prefix}_{datetime.now().isoformat().replace(':', '')}.log" + logDir.mkdir(parents=True, exist_ok=True) setup_logging(self.logLevel, logFile) self.logger = logging.getLogger(__name__) @@ -148,40 +134,41 @@ def move(self, acquisition, intendedForList): """Move an acquisition to BIDS format""" for srcFile in glob(acquisition.srcRoot + ".*"): - _, ext = splitext_(srcFile) - dstFile = os.path.join(self.bidsDir, acquisition.dstRoot + ext) + ext = Path(srcFile).suffixes + dstFile = (self.bidsDir / acquisition.dstRoot).with_suffix("".join(ext)) - if not os.path.exists(os.path.dirname(dstFile)): - os.makedirs(os.path.dirname(dstFile)) + dstFile.parent.mkdir(parents = True, exist_ok = True) # checking if destination file exists - if os.path.isfile(dstFile): + if dstFile.exists(): self.logger.info("'%s' already exists", dstFile) if self.clobber: - self.logger.info("Overwriting because of 'clobber' option") + self.logger.info("Overwriting because of --clobber option") else: - self.logger.info("Use clobber option to overwrite") + self.logger.info("Use --clobber option to overwrite") continue # it's an anat nifti file and the user using a deface script if ( self.config.get("defaceTpl") - and acquisition.dataType == "anat" + and acquisition.dataType == "func" and ".nii" in ext - ): + ): try: os.remove(dstFile) except FileNotFoundError: pass defaceTpl = self.config.get("defaceTpl") - cmd = defaceTpl.format(srcFile=srcFile, dstFile=dstFile) + cmd = [w.replace('srcFile', srcFile) for w in defaceTpl] + cmd = [w.replace('dstFile', dstFile) for w in defaceTpl] run_shell_command(cmd) - intendedForList[acquisition.indexSidecar].append(acquisition.dstIntendedFor + ext) - elif ext == ".json": + intendedForList[acquisition.indexSidecar].append(acquisition.dstIntendedFor + "".join(ext)) + + elif ".json" in ext: data = acquisition.dstSidecarData(self.config["descriptions"], intendedForList) save_json(dstFile, data) @@ -198,102 +185,56 @@ def move(self, acquisition, intendedForList): return intendedForList -def get_arguments(): - """Load arguments for main""" - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description=""" -Reorganising NIfTI files from dcm2niix into the Brain Imaging Data Structure -dcm2bids {}""".format( - __version__ - ), - epilog=""" - Documentation at https://github.com/unfmontreal/Dcm2Bids - """, - ) - - parser.add_argument( - "-d", "--dicom_dir", required=True, nargs="+", help="DICOM directory(ies)" - ) - - parser.add_argument("-p", "--participant", required=True, help="Participant ID") - - parser.add_argument( - "-s", "--session", required=False, default=DEFAULT.cliSession, help="Session ID" - ) - - parser.add_argument( - "-c", - "--config", - required=True, - help="JSON configuration file (see example/config.json)", - ) - - parser.add_argument( - "-o", - "--output_dir", - required=False, - default=DEFAULT.cliOutputDir, - help="Output BIDS directory, Default: current directory ({})".format( - DEFAULT.cliOutputDir - ), - ) - - parser.add_argument( - "--forceDcm2niix", - required=False, - action="store_true", - help="Overwrite previous temporary dcm2niix output if it exists", - ) - - parser.add_argument( - "--clobber", - required=False, - action="store_true", - help="Overwrite output if it exists", - ) - - parser.add_argument( - "-l", - "--log_level", - required=False, - default=DEFAULT.cliLogLevel, - choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - help="Set logging level", - ) - - parser.add_argument( - "-a", - "--anonymizer", - required=False, - action="store_true", - help=""" - This option no longer exists from the script in this release. - See:https://github.com/unfmontreal/Dcm2Bids/blob/master/README.md#defaceTpl""", - ) +def _build_arg_parser(): + p = argparse.ArgumentParser(description=__doc__, epilog=DEFAULT.EPILOG, + formatter_class=argparse.RawTextHelpFormatter) - args = parser.parse_args() - return args + p.add_argument("-d", "--dicom_dir", + type=Path, required=True, nargs="+", + help="DICOM directory(ies).") + + p.add_argument("-p", "--participant", + required=True, + help="Participant ID.") + + p.add_argument("-s", "--session", + required=False, + default="", + help="Session ID.") + + p.add_argument("-c", "--config", + type=Path, + required=True, + help="JSON configuration file (see example/config.json).") + + p.add_argument("-o", "--output_dir", + required=False, + type=Path, + default=Path.cwd(), + help="Output BIDS directory. (Default: %(default)s)") + + p.add_argument("--forceDcm2niix", + action="store_true", + help="Overwrite previous temporary dcm2niix " + "output if it exists.") + + p.add_argument("--clobber", + action="store_true", + help="Overwrite output if it exists.") + + p.add_argument("-l", "--log_level", + required=False, + default=DEFAULT.cliLogLevel, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Set logging level. [%(default)s]") + + return p def main(): """Let's go""" - args = get_arguments() - - if args.anonymizer: - print( - """ - The anonymizer option no longer exists from the script in this release - It is still possible to deface the anatomical nifti images - Please add "defaceTpl" key in the congifuration file - - For example, if you use the last version of pydeface, add: - "defaceTpl": "pydeface --outfile {dstFile} {srcFile}" - It is a template string and dcm2bids will replace {srcFile} and {dstFile} - by the source file (input) and the destination file (output) - """ - ) - return 1 + parser = _build_arg_parser() + args = parser.parse_args() check_latest() check_latest("dcm2niix") diff --git a/dcm2bids/dcm2niix.py b/dcm2bids/dcm2niix.py index 5efcbe89..60ac51ae 100644 --- a/dcm2bids/dcm2niix.py +++ b/dcm2bids/dcm2niix.py @@ -4,6 +4,8 @@ import logging import os +from pathlib import Path +import shlex import shutil from glob import glob from .utils import DEFAULT, run_shell_command @@ -40,11 +42,9 @@ def outputDir(self): Returns: A directory to save all the output files of dcm2niix """ - if self.participant: - tmpDir = self.participant.prefix - else: - tmpDir = DEFAULT.helperDir - return os.path.join(self.bidsDir, DEFAULT.tmpDirName, tmpDir) + tmpDir = self.participant.prefix if self.participant else DEFAULT.helperDir + + return self.bidsDir / DEFAULT.tmpDirName / tmpDir def run(self, force=False): """ Run dcm2niix if necessary @@ -95,8 +95,8 @@ def execute(self): """ Execute dcm2niix for each directory in dicomDirs """ for dicomDir in self.dicomDirs: - commandTpl = "dcm2niix {} -o {} {}" - cmd = commandTpl.format(self.options, self.outputDir, dicomDir) + cmd = ['dcm2niix', *shlex.split(self.options), + '-o', self.outputDir, dicomDir] output = run_shell_command(cmd) try: diff --git a/dcm2bids/helper.py b/dcm2bids/helper.py index 06c4bdce..bcb77ffd 100644 --- a/dcm2bids/helper.py +++ b/dcm2bids/helper.py @@ -4,27 +4,27 @@ import argparse import os +from pathlib import Path import sys -from .dcm2niix import Dcm2niix -from .utils import DEFAULT, assert_dirs_empty -EPILOG = """ - Documentation at https://github.com/unfmontreal/Dcm2Bids - """ +from dcm2bids.dcm2niix import Dcm2niix +from dcm2bids.utils import DEFAULT, assert_dirs_empty def _build_arg_parser(): - p = argparse.ArgumentParser(description=__doc__, epilog=EPILOG, + p = argparse.ArgumentParser(description=__doc__, epilog=DEFAULT.EPILOG, formatter_class=argparse.RawTextHelpFormatter) p.add_argument("-d", "--dicom_dir", + type=Path, required=True, nargs="+", help="DICOM files directory.") p.add_argument("-o", "--output_dir", - required=False, default=DEFAULT.cliOutputDir, - help="Output BIDS directory." - " (Default: %(default)s)") + required=False, default=Path.cwd(), + type=Path, + help="Output BIDS directory. " + "(Default: %(default)s)") p.add_argument('--force', dest='overwrite', action='store_true', @@ -37,12 +37,11 @@ def main(): """Let's go""" parser = _build_arg_parser() args = parser.parse_args() - out_folder = os.path.join(args.output_dir, 'tmp_dcm2bids', 'helper') + out_folder = args.output_dir / DEFAULT.tmpDirName / DEFAULT.helperDir assert_dirs_empty(parser, args, out_folder) app = Dcm2niix(dicomDirs=args.dicom_dir, bidsDir=args.output_dir) rsl = app.run() - print("Example in:") - print(os.path.join(args.output_dir, DEFAULT.tmpDirName, DEFAULT.helperDir)) + print(f"Example in: {out_folder}") return rsl diff --git a/dcm2bids/sidecar.py b/dcm2bids/sidecar.py index f3ad1ae5..a973765b 100644 --- a/dcm2bids/sidecar.py +++ b/dcm2bids/sidecar.py @@ -86,7 +86,7 @@ class SidecarPairing(object): """ Args: sidecars (list): List of Sidecar objects - descriptions (list): List of dictionnaries describing acquisitions + descriptions (list): List of dictionaries describing acquisitions """ def __init__(self, sidecars, descriptions, searchMethod=DEFAULT.searchMethod, @@ -146,7 +146,7 @@ def caseSensitive(self, value): def build_graph(self): """ Test all the possible links between the list of sidecars and the - description dictionnaries and build a graph from it + description dictionaries and build a graph from it The graph is in a OrderedDict object. The keys are the Sidecars and the values are a list of possible descriptions diff --git a/dcm2bids/structure.py b/dcm2bids/structure.py index 72156820..97b08aa4 100644 --- a/dcm2bids/structure.py +++ b/dcm2bids/structure.py @@ -251,12 +251,12 @@ def setDstFile(self): self.logger.warning("Entity \"{}\"".format(list(current_dict.keys())) + " is not a valid BIDS entity.") - new_name += f"_{'_'.join(suffix_list)}" # Allow multiple single key (without value) + new_name += f"_{'_'.join(suffix_list)}" # Allow multiple single keys (without value) if len(suffix_list) != 1: self.logger.warning("There was more than one suffix found " - f"({suffix_list}). this is not BIDS " - "compliant. Make sure you know what" + f"({suffix_list}). This is not BIDS " + "compliant. Make sure you know what " "you are doing.") if current_name != new_name: diff --git a/dcm2bids/utils.py b/dcm2bids/utils.py index 626cd31d..d487157f 100644 --- a/dcm2bids/utils.py +++ b/dcm2bids/utils.py @@ -5,6 +5,8 @@ import json import logging import os +from pathlib import Path +import re from collections import OrderedDict import shlex import shutil @@ -15,13 +17,12 @@ class DEFAULT(object): """ Default values of the package""" # cli dcm2bids - cliSession = "" - cliOutputDir = os.getcwd() cliLogLevel = "INFO" + EPILOG="Documentation at https://github.com/unfmontreal/Dcm2Bids" # dcm2bids.py - outputDir = cliOutputDir - session = cliSession # also Participant object + outputDir = Path.cwd() + session = "" # also Participant object clobber = False forceDcm2niix = False defaceTpl = None @@ -64,7 +65,7 @@ def load_json(filename): def save_json(filename, data): - with open(filename, "w") as f: + with filename.open("w") as f: json.dump(data, f, indent=4) @@ -111,14 +112,37 @@ def splitext_(path, extensions=None): def run_shell_command(commandLine): """ Wrapper of subprocess.check_output - Returns: Run command with arguments and return its output """ logger = logging.getLogger(__name__) logger.info("Running %s", commandLine) - return check_output(shlex.split(commandLine)) + return check_output(commandLine) + + +def valid_path(in_path, type="folder"): + """Assert that file exists. + Parameters + ---------- + required_file: Path + Path to be checked. + """ + if isinstance(in_path, str): + in_path = Path(in_path) + + if type == 'folder': + if in_path.is_dir() or in_path.parent.is_dir(): + return in_path + else: + raise NotADirectoryError(in_path) + elif type == "file": + if in_path.is_file(): + return in_path + else: + raise FileNotFoundError(in_path) + + raise TypeError(type) def assert_dirs_empty(parser, args, required): """ @@ -137,25 +161,30 @@ def assert_dirs_empty(parser, args, required): If true, create the directory if it does not exist. """ def check(path): - if os.path.isdir(path): - if not args.overwrite: - parser.error( - f"Output directory {path} isn't empty, so some files " - "could be overwritten or deleted.\nRerun the command with " - "--force option to overwrite existing output files.") - else: - for the_file in os.listdir(path): - file_path = os.path.join(path, the_file) - try: - if os.path.isfile(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - print(e) - - if isinstance(required, str): - required = [required] + if not path.is_dir(): + return + + if not any(path.iterdir()): + return + + if not args.overwrite: + parser.error( + f"Output directory {path} isn't empty, so some files " + "could be overwritten or deleted.\nRerun the command with " + "--force option to overwrite existing output files.") + else: + for the_file in path.iterdir(): + file_path = path / the_file + try: + if file_path.is_file(): + file_path.unlink() + elif file_path.is_dir(): + shutil.rmtree(file_path) + except Exception as e: + print(e) + + if isinstance(required, str) or isinstance(required, Path): + required = [Path(required)] for cur_dir in required: check(cur_dir) diff --git a/docs/how-to/use-advanced-commands.md b/docs/how-to/use-advanced-commands.md index 4ac92674..a8185c83 100644 --- a/docs/how-to/use-advanced-commands.md +++ b/docs/how-to/use-advanced-commands.md @@ -7,7 +7,7 @@ same level as the `"descriptions"` entry. { "searchMethod": "fnmatch", "caseSensitive": true, - "defaceTpl": "pydeface --outfile {dstFile} {srcFile}", + "defaceTpl": ["pydeface", "--outfile", "dstFile", "srcFile"], "description": [ ... ]