Repository: ShawHahnLab/igseq

Issue:
Support extra command-line arguments for bcl2fastq in getreads command
It should be possible to specify custom bcl2fastq arguments (.e.g. `--with-failed-reads`) to be added to the command.  Should be easy enough by following the pattern already used for the igblast command.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1b315d..908cee6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,12 @@
 
 ### Added
 
+ * support for additional arguments for `getreads` command passed through to
+   bcl2fastq ([#43])
  * `msa` command for building multiple sequence alignments with
    [MUSCLE](https://drive5.com/muscle5/) ([#41])
 
+[#43]: https://github.com/ShawHahnLab/igseq/pull/43
 [#41]: https://github.com/ShawHahnLab/igseq/pull/41
 
 ## 0.4.0 - 2022-09-17
diff --git a/igseq/__main__.py b/igseq/__main__.py
index 0d27193..f86f87b 100644
--- a/igseq/__main__.py
+++ b/igseq/__main__.py
@@ -78,10 +78,9 @@ def main(arglist=None):
         try:
             if args_extra:
                 # If there were unparsed arguments, see if we're in one of the
-                # commands (currently just igblast) that can take extra
-                # pass-through arguments.  If so pass them along, but if not,
-                # error out.
-                if args.func in [_main_igblast]:
+                # commands that can take extra pass-through arguments.  If so
+                # pass them along, but if not, error out.
+                if args.func in [_main_igblast, _main_getreads]:
                     args.func(args, args_extra)
                 else:
                     parser.parse_args(args_extra)
@@ -112,13 +111,14 @@ def main(arglist=None):
         except BrokenPipeError:
             os.dup2(devnull, sys.stderr.fileno())
 
-def _main_getreads(args):
+def _main_getreads(args, extra_args=None):
     if args.no_counts:
         args.countsfile = None
     getreads.getreads(
         path_input=args.input,
         dir_out=args.outdir,
         path_counts=args.countsfile,
+        extra_args=extra_args,
         threads_load=args.threads_load,
         threads_proc=args.threads,
         dry_run=args.dry_run)
diff --git a/igseq/getreads.py b/igseq/getreads.py
index 1a65a4a..2136653 100644
--- a/igseq/getreads.py
+++ b/igseq/getreads.py
@@ -9,6 +9,10 @@ so they can be used later during demultiplexing.
 
 This step can be skipped if you already have all of your reads in single trio
 of I1/R1/R2 fastq.gz files.
+
+Any command-line arguments not recognized here are passed as-is to the
+bcl2fastq command, like the igblast command allows.  See bcl2fastq --help for
+those options.
 """
 
 import logging
@@ -22,7 +26,7 @@ LOGGER = logging.getLogger(__name__)
 
 BCL2FASTQ = "bcl2fastq"
 
-def getreads(path_input, dir_out, path_counts="", threads_load=1, threads_proc=1, dry_run=False):
+def getreads(path_input, dir_out, path_counts="", extra_args=None, threads_load=1, threads_proc=1, dry_run=False):
     """Get reads directly from Illumina run directory.
 
     path_input: path to an Illumina run directory
@@ -30,6 +34,8 @@ def getreads(path_input, dir_out, path_counts="", threads_load=1, threads_proc=1
     path_counts: path to csv to write read counts to.  If an empty
                  string, dir_out/getreads.counts.csv is used.  If None, this
                  file isn't written.
+    extra_args: list of arguments to pass to bcl2fastq command.  Must not
+                overlap with the arguments set here.
     threads_load: number of threads for parallel loading
     threads_proc: number of threads for parallel processing
     dry_run: If True, don't actually call any commands or write any files.
@@ -54,6 +60,7 @@ def getreads(path_input, dir_out, path_counts="", threads_load=1, threads_proc=1
         dir_out = Path(dir_out)
     LOGGER.info("input: %s", path_input)
     LOGGER.info("output: %s", dir_out)
+    LOGGER.info("extra args: %s", extra_args)
 
     if path_counts == "":
         path_counts = dir_out / "getreads.counts.csv"
@@ -102,7 +109,7 @@ def getreads(path_input, dir_out, path_counts="", threads_load=1, threads_proc=1
                 "--writing-threads", 1,
                 "--min-log-level", log_level]
             try:
-                _run_bcl2fastq(args)
+                _run_bcl2fastq(args, extra_args)
             except subprocess.CalledProcessError as err:
                 msg = f"bcl2fastq exited with code {err.returncode}"
                 LOGGER.critical(msg)
@@ -147,7 +154,15 @@ def count_bcl2fastq_reads(summary_txt):
                 cts["extra-pf"] += int(row["NumberOfReadsPF"])
     return cts
 
-def _run_bcl2fastq(args):
+def _run_bcl2fastq(args, extra_args=None):
     args = [BCL2FASTQ] + [str(arg) for arg in args]
+    if extra_args:
+        # make sure none of the extra arguments, if there are any, clash with
+        # the ones we've defined above.
+        args_dashes = {arg for arg in args if str(arg).startswith("-")}
+        shared = args_dashes & set(extra_args)
+        if shared:
+            raise util.IgSeqError(f"bcl2fastq arg collision from extra arguments: {shared}")
+        args += extra_args
     LOGGER.info("bcl2fastq command: %s", args)
     subprocess.run(args, check=True)


Test patch:
diff --git a/test_igseq/test_getreads.py b/test_igseq/test_getreads.py
index 53f79bf..4865add 100644
--- a/test_igseq/test_getreads.py
+++ b/test_igseq/test_getreads.py
@@ -34,7 +34,7 @@ class TestGetreads(TestBase):
 
         When called it will make empty files with the expected filenames.
         """
-        def side_effect(args):
+        def side_effect(args, extra_args=None):
             idx = args.index("--output-dir") + 1
             path = Path(args[idx])
             path.mkdir(parents=True, exist_ok=True)
@@ -84,7 +84,7 @@ class TestGetreads(TestBase):
         self.mock.assert_called_once()
         self.assertEqual(args_exp, args_obs)
         # There should be some info message but nothing higher than that
-        self.assertEqual(logcounts, {"INFO": 4})
+        self.assertEqual(logcounts, {"INFO": 5})
 
 
 class TestGetreadsMissingFiles(TestGetreads):
@@ -93,7 +93,7 @@ class TestGetreadsMissingFiles(TestGetreads):
     @classmethod
     def make_mock_bcl2fastq(cls):
         """Mock _run_bcl2fastq that doesn't make any of the expected output files."""
-        def side_effect(args):
+        def side_effect(args, extra_args=None):
             idx = args.index("--output-dir") + 1
             path = Path(args[idx])
             path.mkdir(parents=True, exist_ok=True)
@@ -111,7 +111,7 @@ class TestGetreadsMissingFiles(TestGetreads):
         self.mock.assert_called_once()
         # There should be some info messages and also errors about the missing
         # files
-        self.assertEqual(logcounts, {"INFO": 4, "CRITICAL": 2})
+        self.assertEqual(logcounts, {"INFO": 5, "CRITICAL": 2})
 
 
 class TestGetreadsExtraFiles(TestGetreads):
@@ -120,7 +120,7 @@ class TestGetreadsExtraFiles(TestGetreads):
     @classmethod
     def make_mock_bcl2fastq(cls):
         """Mock _run_bcl2fastq that create extra files."""
-        def side_effect(args):
+        def side_effect(args, extra_args=None):
             idx = args.index("--output-dir") + 1
             path = Path(args[idx])
             path.mkdir(parents=True, exist_ok=True)
@@ -145,7 +145,7 @@ class TestGetreadsExtraFiles(TestGetreads):
         self.mock.assert_called_once()
         # There should be some info messages and also a warning about the extra
         # files
-        self.assertEqual(logcounts, {"INFO": 4, "WARNING": 1})
+        self.assertEqual(logcounts, {"INFO": 5, "WARNING": 1})
 
 
 class TestGetreadsLive(TestGetreads, TestLive):