Repository: biopython/biopython

Issue:
Bug when parsing origin-spanning features that span an entire circular sequence
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
3.11.6 (main, Oct  6 2023, 11:55:02) [Clang 13.0.0 (clang-1300.0.29.30)]
CPython
macOS-12.7-x86_64-i386-64bit
1.81
```

### Expected behaviour

In a circular sequence of length 4, the location `3..2`, which spans the entire sequence spanning the origin and starting at position 3, should give a location `join{[2:4](+), [0:2](+)}`.

### Actual behaviour

That feature is parsed as `[2:2](+)`, which is an empty slice that when extracted gives an empty sequence.

### Steps to reproduce

cc @peterjc 

Happy to suggest a fix and include a test if this is considered a bug. See the comments for observed vs. expected behaviour.

```python
from Bio.SeqIO import read
from io import StringIO

example = """
LOCUS       pj5_00001                  4 bp ds-DNA     circular     05-DEC-2023
DEFINITION  .
COMMENT     Imported using the Genbank importer. File name: pj5_00001 (1).gb
            teselagen_unique_id: 5adf735aa1811801e17d8aac
FEATURES             Location/Qualifiers
     misc_feature    3..2
                     /label="forward"
     misc_feature    complement(3..2)
                     /label="reverse"
ORIGIN
        1 acgt
//
"""

seq = read(StringIO(example), 'genbank')

print(seq.features[0].location) # [2:2](+) / Should be join{[2:4](+), [0:2](+)}
print(seq.features[1].location) # [2:2](-) / Should be join{[0:2](-), [2:4](-)}

print(seq.features[0].extract(seq).seq) # Gives empty sequence
print(seq.features[1].extract(seq).seq) # Gives empty sequence

example = """
LOCUS       pj5_00001                  4 bp ds-DNA     circular     05-DEC-2023
DEFINITION  .
COMMENT     Imported using the Genbank importer. File name: pj5_00001 (1).gb
            teselagen_unique_id: 5adf735aa1811801e17d8aac
FEATURES             Location/Qualifiers
     misc_feature    4..2
                     /label="forward"
     misc_feature    complement(4..2)
                     /label="reverse"
ORIGIN
        1 acgt
//
"""

seq = read(StringIO(example), 'genbank')

print(seq.features[0].location) # join{[3:4](+), [0:2](+)}
print(seq.features[1].location) # join{[0:2](-), [3:4](-)}

print(seq.features[0].extract(seq).seq) # Gives TAC
print(seq.features[1].extract(seq).seq) # Gives GTA

```

Bug when parsing origin-spanning features that span an entire circular sequence
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```python
3.11.6 (main, Oct  6 2023, 11:55:02) [Clang 13.0.0 (clang-1300.0.29.30)]
CPython
macOS-12.7-x86_64-i386-64bit
1.81
```

### Expected behaviour

In a circular sequence of length 4, the location `3..2`, which spans the entire sequence spanning the origin and starting at position 3, should give a location `join{[2:4](+), [0:2](+)}`.

### Actual behaviour

That feature is parsed as `[2:2](+)`, which is an empty slice that when extracted gives an empty sequence.

### Steps to reproduce

cc @peterjc 

Happy to suggest a fix and include a test if this is considered a bug. See the comments for observed vs. expected behaviour.

```python
from Bio.SeqIO import read
from io import StringIO

example = """
LOCUS       pj5_00001                  4 bp ds-DNA     circular     05-DEC-2023
DEFINITION  .
COMMENT     Imported using the Genbank importer. File name: pj5_00001 (1).gb
            teselagen_unique_id: 5adf735aa1811801e17d8aac
FEATURES             Location/Qualifiers
     misc_feature    3..2
                     /label="forward"
     misc_feature    complement(3..2)
                     /label="reverse"
ORIGIN
        1 acgt
//
"""

seq = read(StringIO(example), 'genbank')

print(seq.features[0].location) # [2:2](+) / Should be join{[2:4](+), [0:2](+)}
print(seq.features[1].location) # [2:2](-) / Should be join{[0:2](-), [2:4](-)}

print(seq.features[0].extract(seq).seq) # Gives empty sequence
print(seq.features[1].extract(seq).seq) # Gives empty sequence

example = """
LOCUS       pj5_00001                  4 bp ds-DNA     circular     05-DEC-2023
DEFINITION  .
COMMENT     Imported using the Genbank importer. File name: pj5_00001 (1).gb
            teselagen_unique_id: 5adf735aa1811801e17d8aac
FEATURES             Location/Qualifiers
     misc_feature    4..2
                     /label="forward"
     misc_feature    complement(4..2)
                     /label="reverse"
ORIGIN
        1 acgt
//
"""

seq = read(StringIO(example), 'genbank')

print(seq.features[0].location) # join{[3:4](+), [0:2](+)}
print(seq.features[1].location) # join{[0:2](-), [3:4](-)}

print(seq.features[0].extract(seq).seq) # Gives TAC
print(seq.features[1].extract(seq).seq) # Gives GTA

```



Discussion:
Your GenBank example looks wrong, ``3..2`` should perhaps be ``2..3`` as the values should be increasing left to right.
Hi @peterjc this type of notation is frequent in addgene plasmids for origin spanning features in circular molecules, e.g. [this plasmid](https://media.addgene.org/snapgene-media/v1.7.9-0-g88a3305/sequences/271056/9a1d5286-e91f-4106-8b96-5dbecced7e1d/addgene-plasmid-134441-sequence-271056.gbk)

```
     gene            4306..1468
                     /label=hphMX6
                     /note="yeast selectable marker conferring hygromycin 
                     resistance"
     promoter        4306..236
                     /label=TEF promoter
                     /note="Ashbya gossypii TEF promoter"
```

Origin spanning features written like this are correctly parsed by biopython as by my second example above where `4..2` is parsed as `join{[3:4](+), [0:2](+)}` and `complement(4..2)` as `join{[0:2](-), [3:4](-)}`.

The problem seems to occur when the feature spans the entire circular sequence (see first example)
Ah, your GenBank file should probably have ``join(3..4,1..2)`` then.

Biopython makes a best guess with invalid files using ``i..j`` with i > j, and should give a warning. It looks like it isn't handling a corner case quite right...
So, the magic happens here:

https://github.com/biopython/biopython/blob/biopython-181/Bio/SeqFeature.py#L1099

```python
            s, e = text.split("..")
            # Attempt to fix features that span the origin
            s_pos = Position.fromstring(s, -1)
            e_pos = Position.fromstring(e)
            if s_pos > e_pos:
                # There is likely a problem with origin wrapping.
                # Create a CompoundLocation of the wrapped feature,
                # consisting of two SimpleLocation objects to extend to
                # the list of feature locations.
                ...
```

Starting with ``3..2`` would give ``s_pos = 3 - 1 = 2`` and ``e_pos = 2``, and fails that inequality. I think changing to ``if s_pos >= e_pos:`` would solve your example?

Anyway, when that if test fails (as happens currently with your test case), it ought to do ``raise LocationParserError(f"invalid feature location '{text}'")``, but it does not. That is another bug.

Does that make sense? Do you want try it, and then make a pull request? Or would you rather I did it and you tested it?
Your GenBank example looks wrong, ``3..2`` should perhaps be ``2..3`` as the values should be increasing left to right.
Hi @peterjc this type of notation is frequent in addgene plasmids for origin spanning features in circular molecules, e.g. [this plasmid](https://media.addgene.org/snapgene-media/v1.7.9-0-g88a3305/sequences/271056/9a1d5286-e91f-4106-8b96-5dbecced7e1d/addgene-plasmid-134441-sequence-271056.gbk)

```
     gene            4306..1468
                     /label=hphMX6
                     /note="yeast selectable marker conferring hygromycin 
                     resistance"
     promoter        4306..236
                     /label=TEF promoter
                     /note="Ashbya gossypii TEF promoter"
```

Origin spanning features written like this are correctly parsed by biopython as by my second example above where `4..2` is parsed as `join{[3:4](+), [0:2](+)}` and `complement(4..2)` as `join{[0:2](-), [3:4](-)}`.

The problem seems to occur when the feature spans the entire circular sequence (see first example)
Ah, your GenBank file should probably have ``join(3..4,1..2)`` then.

Biopython makes a best guess with invalid files using ``i..j`` with i > j, and should give a warning. It looks like it isn't handling a corner case quite right...
So, the magic happens here:

https://github.com/biopython/biopython/blob/biopython-181/Bio/SeqFeature.py#L1099

```python
            s, e = text.split("..")
            # Attempt to fix features that span the origin
            s_pos = Position.fromstring(s, -1)
            e_pos = Position.fromstring(e)
            if s_pos > e_pos:
                # There is likely a problem with origin wrapping.
                # Create a CompoundLocation of the wrapped feature,
                # consisting of two SimpleLocation objects to extend to
                # the list of feature locations.
                ...
```

Starting with ``3..2`` would give ``s_pos = 3 - 1 = 2`` and ``e_pos = 2``, and fails that inequality. I think changing to ``if s_pos >= e_pos:`` would solve your example?

Anyway, when that if test fails (as happens currently with your test case), it ought to do ``raise LocationParserError(f"invalid feature location '{text}'")``, but it does not. That is another bug.

Does that make sense? Do you want try it, and then make a pull request? Or would you rather I did it and you tested it?

Resolving patch:
diff --git a/Bio/SeqFeature.py b/Bio/SeqFeature.py
--- a/Bio/SeqFeature.py
+++ b/Bio/SeqFeature.py
@@ -909,7 +909,7 @@ def fromstring(text, length=None, circular=False):
         except ValueError:
             pass
         else:
-            if 0 <= s <= e:
+            if 0 <= s < e:
                 return SimpleLocation(s, e, strand)
         # Try general case
         try:
@@ -941,7 +941,7 @@ def fromstring(text, length=None, circular=False):
             # Attempt to fix features that span the origin
             s_pos = Position.fromstring(s, -1)
             e_pos = Position.fromstring(e)
-            if s_pos > e_pos:
+            if s_pos >= e_pos:
                 # There is likely a problem with origin wrapping.
                 # Create a CompoundLocation of the wrapped feature,
                 # consisting of two SimpleLocation objects to extend to


Test patch:
diff --git a/Tests/test_SeqFeature.py b/Tests/test_SeqFeature.py
--- a/Tests/test_SeqFeature.py
+++ b/Tests/test_SeqFeature.py
@@ -337,6 +337,26 @@ def test_reference_in_compound_location_sequence(self):
         self.assertEqual(type(sequence), Seq.Seq)
         self.assertEqual(sequence, "ccaatgg")
 
+    def test_origin_spanning_location(self):
+        """Test location spanning origin."""
+        # Regular origin-spanning sequence
+        self.assertEqual(
+            str(SimpleLocation.fromstring("4..2", 4, True)), "join{[3:4], [0:2]}"
+        )
+        self.assertEqual(
+            str(SimpleLocation.fromstring("complement(4..2)", 4, True)),
+            "join{[0:2](-), [3:4](-)}",
+        )
+
+        # Origin-spanning location containing the entire sequence
+        self.assertEqual(
+            str(SimpleLocation.fromstring("3..2", 4, True)), "join{[2:4], [0:2]}"
+        )
+        self.assertEqual(
+            str(SimpleLocation.fromstring("complement(3..2)", 4, True)),
+            "join{[0:2](-), [2:4](-)}",
+        )
+
 
 if __name__ == "__main__":
     runner = unittest.TextTestRunner(verbosity=2)