Repository: biopython/biopython

Issue:
Parsing Exonerate text output with SearchIO does not assign target strand correctly
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```
Python 3.8.3 (default, May 19 2020, 13:54:14)
CPython
macOS-10.15.7-x86_64-i386-64bit
BioPython 1.78
```

### Expected behaviour

When parsing the output of an Exonerate search performed using the model `protein2genome`, `SearchIO.parse(<exonerate_output_file>, 'exonerate-text')` should assign hsp strands to `-` (converted to `-1) when the nucleotide hit is on the negative strand. 

### Actual behaviour

All hsps get assigned to the positive strand `+` (converted to `1`), regardless of whether the nucleotide hit is on the positive or negative strand.


### Steps to reproduce

Tested with Exonerate v2.2 and v2.4.

Using the attached Exonerate output file  [exonerate_results.fasta.zip](https://github.com/biopython/biopython/files/7263583/exonerate_results.fasta.zip) the SearchIO command is:

`exonerate_hits_from_alignment = list(SearchIO.parse('exonerate_results.fasta', 'exonerate-text'))`

Nucleotide hits are on the negative strand (target description contains `[revcomp]`). However:

```
for qresult in exonerate_hits_from_alignment:
    for hsp in qresult.hsps:
        print(hsp.hit_strand_all)
```

Output:
```
[1]
[1]
[1]
[1]
[1]
[1]
[1, 1]
[1]
```

I think this might be due to the code [here](https://github.com/biopython/biopython/blob/1e4acd3cc6a1ef1c79da187b0aaea8a34586d6d9/Bio/SearchIO/ExonerateIO/_base.py#L391); the strand is set based on the presence of the string `:[revcomp]` in the query or target description line, whereas the Exonerate output in version 2.2 and 2.4 contains `[revcomp]` instead , i.e. no preceeding colon.

Cheers,

Chris


Parsing Exonerate text output with SearchIO does not assign target strand correctly
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```
Python 3.8.3 (default, May 19 2020, 13:54:14)
CPython
macOS-10.15.7-x86_64-i386-64bit
BioPython 1.78
```

### Expected behaviour

When parsing the output of an Exonerate search performed using the model `protein2genome`, `SearchIO.parse(<exonerate_output_file>, 'exonerate-text')` should assign hsp strands to `-` (converted to `-1) when the nucleotide hit is on the negative strand. 

### Actual behaviour

All hsps get assigned to the positive strand `+` (converted to `1`), regardless of whether the nucleotide hit is on the positive or negative strand.


### Steps to reproduce

Tested with Exonerate v2.2 and v2.4.

Using the attached Exonerate output file  [exonerate_results.fasta.zip](https://github.com/biopython/biopython/files/7263583/exonerate_results.fasta.zip) the SearchIO command is:

`exonerate_hits_from_alignment = list(SearchIO.parse('exonerate_results.fasta', 'exonerate-text'))`

Nucleotide hits are on the negative strand (target description contains `[revcomp]`). However:

```
for qresult in exonerate_hits_from_alignment:
    for hsp in qresult.hsps:
        print(hsp.hit_strand_all)
```

Output:
```
[1]
[1]
[1]
[1]
[1]
[1]
[1, 1]
[1]
```

I think this might be due to the code [here](https://github.com/biopython/biopython/blob/1e4acd3cc6a1ef1c79da187b0aaea8a34586d6d9/Bio/SearchIO/ExonerateIO/_base.py#L391); the strand is set based on the presence of the string `:[revcomp]` in the query or target description line, whereas the Exonerate output in version 2.2 and 2.4 contains `[revcomp]` instead , i.e. no preceeding colon.

Cheers,

Chris




Discussion:
Your analysis sounds sensible - do you fancy making a pull request to address this? Ideally with a small test case from a recent version of Exonerate?

CC @bow
Your analysis sounds sensible - do you fancy making a pull request to address this? Ideally with a small test case from a recent version of Exonerate?

CC @bow

Resolving patch:
diff --git a/Bio/SearchIO/ExonerateIO/_base.py b/Bio/SearchIO/ExonerateIO/_base.py
--- a/Bio/SearchIO/ExonerateIO/_base.py
+++ b/Bio/SearchIO/ExonerateIO/_base.py
@@ -311,6 +311,35 @@ def _parse_hit_or_query_line(line):
     return id, desc
 
 
+def _get_strand_from_desc(desc, is_protein, modify_desc=True):
+    """Determine the strand from the description (PRIVATE).
+
+    Exonerate appends ``:[revcomp]`` (versions <= 2.2) or ``[revcomp]``
+    (versions > 2.2) to the query and/or hit description string. This function
+    outputs '-' if the description has such modifications or '+' if not. If the
+    query and/or hit is a protein sequence, a '.' is output instead.
+
+    Aside from the strand, the input description value is also returned. It is
+    returned unmodified if ``modify_desc`` is ``False``. Otherwise, the appended
+    ``:[revcomp]`` or ``[revcomp]`` is removed.
+
+    """
+    if is_protein:
+        return ".", desc
+
+    suffix = ""
+    if desc.endswith("[revcomp]"):
+        suffix = ":[revcomp]" if desc.endswith(":[revcomp]") else "[revcomp]"
+
+    if not suffix:
+        return "+", desc
+
+    if modify_desc:
+        return "-", desc[: -len(suffix)]
+
+    return "-", desc
+
+
 class _BaseExonerateParser(ABC):
     """Abstract base class iterator for exonerate format."""
 
@@ -389,20 +418,21 @@ def _parse_alignment_header(self):
                 hsp["hit_start"], hsp["hit_end"] = line.split(" ", 4)[2:5:2]
 
         # determine strand
-        if qresult["description"].endswith(":[revcomp]"):
-            hsp["query_strand"] = "-"
-            qresult["description"] = qresult["description"].replace(":[revcomp]", "")
-        elif "protein2" in qresult["model"]:
-            hsp["query_strand"] = "."
-        else:
-            hsp["query_strand"] = "+"
-        if hit["description"].endswith(":[revcomp]"):
-            hsp["hit_strand"] = "-"
-            hit["description"] = hit["description"].replace(":[revcomp]", "")
-        elif "2protein" in qresult["model"]:
-            hsp["hit_strand"] = "."
-        else:
-            hsp["hit_strand"] = "+"
+        qresult_strand, qresult_desc = _get_strand_from_desc(
+            desc=qresult["description"],
+            is_protein="protein2" in qresult["model"],
+            modify_desc=True,
+        )
+        hsp["query_strand"] = qresult_strand
+        qresult["description"] = qresult_desc
+
+        hit_strand, hit_desc = _get_strand_from_desc(
+            desc=hit["description"],
+            is_protein="2protein" in qresult["model"],
+            modify_desc=True,
+        )
+        hsp["hit_strand"] = hit_strand
+        hit["description"] = hit_desc
 
         # NOTE: we haven't processed the coordinates types
         # and the strands are not yet Biopython's standard (1 / -1 / 0)


Test patch: