Repository: acorg/dark-matter

Issue:
SAM parsing throws an exception
Fix this:

```sh
$ filter-sam.py --referenceId Sorsum_high_freq --dropSecond --dropSupp --samfile sorsum-assortedGenotypesDieter-bowtie2.sam >/dev/null
Traceback (most recent call last):
  File "/Users/terry/.virtualenvs/lm36/bin/filter-sam.py", line 90, in <module>
    for kept, alignment in enumerate(samFilter.alignments(), start=1):
  File "/Users/terry/.virtualenvs/lm36/lib/python3.6/site-packages/dark/sam.py", line 354, in alignments
    alignment.cigartuples)
  File "/Users/terry/.virtualenvs/lm36/lib/python3.6/site-packages/dark/sam.py", line 88, in _hardClip
    for (operation, length) in cigartuples:
TypeError: 'NoneType' object is not iterable
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a68180a..77e9eb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## 3.0.44 Nov 1, 2018
+
+Fix [636](https://github.com/acorg/dark-matter/issues/636) in which SAM file
+parsing threw an exception when an unmapped sequence with no CIGAR string
+occurred in a SAM file (this can happen when running `bowtie2 --all`).
+
 ## 3.0.43 Nov 1, 2018
 
 Fixed thinko in 3.0.42.
diff --git a/dark/__init__.py b/dark/__init__.py
index f10e319..e8e1c5a 100644
--- a/dark/__init__.py
+++ b/dark/__init__.py
@@ -7,4 +7,4 @@ if sys.version_info < (2, 7):
 # will not be found by the version() function in ../setup.py
 #
 # Remember to update ../CHANGELOG.md describing what's new in each version.
-__version__ = '3.0.43'
+__version__ = '3.0.44'
diff --git a/dark/sam.py b/dark/sam.py
index a4f2c1a..f6052ca 100644
--- a/dark/sam.py
+++ b/dark/sam.py
@@ -332,14 +332,24 @@ class SAMFilter(object):
                 # the previous sequence should be used. This is best
                 # practice according to section 2.5.2 of
                 # https://samtools.github.io/hts-specs/SAMv1.pdf So we use
-                # the last alignment if we get None as a query sequence.
+                # the last alignment query and quality strings if we get
+                # None as a query sequence.
                 if alignment.query_sequence is None:
                     if lastAlignment is None:
                         raise InvalidSAM(
                             'pysam produced an alignment (number %d) with no '
                             'query sequence without previously giving an '
                             'alignment with a sequence.' % count)
-                    # Use the previous query sequence and quality.
+                    # Use the previous query sequence and quality. I'm not
+                    # making the call to _hardClip dependent on
+                    # alignment.cigartuples (as in the else clause below)
+                    # because I don't think it's possible for
+                    # alignment.cigartuples to be None in this case. If we
+                    # have a second match on a query, then it must be
+                    # aligned to something (i.e., it cannot be unmapped
+                    # with no CIGAR string). The assertion will tell us if
+                    # this is ever not the case.
+                    assert alignment.cigartuples
                     (alignment.query_sequence,
                      alignment.query_qualities, _) = _hardClip(
                          lastAlignment.query_sequence,
@@ -347,11 +357,12 @@ class SAMFilter(object):
                          alignment.cigartuples)
                 else:
                     lastAlignment = alignment
-                    (alignment.query_sequence,
-                     alignment.query_qualities, _) = _hardClip(
-                         alignment.query_sequence,
-                         alignment.query_qualities,
-                         alignment.cigartuples)
+                    if alignment.cigartuples:
+                        (alignment.query_sequence,
+                         alignment.query_qualities, _) = _hardClip(
+                             alignment.query_sequence,
+                             alignment.query_qualities,
+                             alignment.cigartuples)
 
                 if ((filterRead is None or
                      filterRead(Read(alignment.query_name,


Test patch:
diff --git a/test/test_sam.py b/test/test_sam.py
index 524f8a2..ab4bf4c 100644
--- a/test/test_sam.py
+++ b/test/test_sam.py
@@ -247,6 +247,24 @@ class TestSAMFilter(TestCase):
             self.assertEqual('query1', alignment1.query_name)
             self.assertEqual('query4', alignment2.query_name)
 
+    def testCloseButNoCIGAR(self):
+        """
+        An unmapped query with no CIGAR string must be passed through
+        unchanged if dropUnmapped is not specified.
+        """
+        data = '\n'.join([
+            '@SQ SN:ref LN:10',
+            'query1 4 * 0 0 * * 0 0 TCTAGG ZZZZZZ',
+        ]).replace(' ', '\t')
+
+        with dataFile(data) as filename:
+            sf = SAMFilter(filename)
+            (alignment,) = list(sf.alignments())
+            self.assertEqual('query1', alignment.query_name)
+            self.assertEqual('TCTAGG', alignment.query_sequence)
+            self.assertEqual('ZZZZZZ', ''.join(
+                map(lambda x: chr(x + 33), alignment.query_qualities)))
+
 
 class TestPaddedSAM(TestCase):
     """