Repository: biopython/biopython

Issue:
Order of coordinates of a multi-part feature that maps to the reverse strand maybe 'wrong' in GenBank file generated from SnapGene file?
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

```
3.7.10 | packaged by conda-forge | (default, Feb 19 2021, 16:07:37) 
[GCC 9.3.0]
CPython
Linux-5.4.72-microsoft-standard-WSL2-x86_64-with-debian-bullseye-sid
1.79
```

### Expected behaviour
Cloning programs/webapps such as SnapGene or Benchling should display all features included in a GenBank file that has been converted from a SnapGene file with BioPython.

### Actual behaviour
Cloning programs/webapps such as SnapGene or Benchling do not display all features included in a GenBank file that has been converted from a SnapGene file with BioPython. The problematic features are those that contain multiple elements on the reverse strand, for example `complement(join(3873..3941,3081..3872))`. If I reverse the order of those elements, that is, `complement(join(3081..3872,3873..3941))`, then everything's good, and the feature is shown in my fav cloning programs/webapp. I guess the elements of a feature that maps to the reverse/complement strand should nonetheless be listed in an 'increasing order'? 

Although it's most likely a coincidence, I also noticed that for the feature mentioned above, BioPython fails to include one of the notes that SnapGene instead includes in the exported file (the one at the very bottom of the left panel)

![Screenshot 2021-07-05 170536](https://user-images.githubusercontent.com/2800360/124495090-f428cb80-ddb7-11eb-957e-f8becf652083.png)

### Steps to reproduce

I am attaching the original SnapGene map and those converted to GenBank with SnapGene and BioPython to this message.

[test_plasmid.zip](https://github.com/biopython/biopython/files/6765312/test_plasmid.zip)


Discussion:
Thank you for sharing a test case.

This sounds like it could be two bugs in the SnapGene code, (a) not handling the order of complement strand joins correctly, and (b) not handling multiple note entries.

@gouttegd are you able to take a look please?
@peterjc I will. You may assign the issue to me.
I confirm the issue with the ordering of complement strand joins, I'll have a patch in the next few days.

The missing `note` qualifier however is a non-issue; our parser correctly handles multiple qualifier values (though I’ll add a test case to cover that, just to be safe). That “missing” `note` is *not* present in the features section of the source SnapGene file, it is dynamically generated by SnapGene when exporting to GenBank.

Basically instead of generating a composite location (`complement(join(...))`), SnapGene generates a simple location covering the entire span of the feature, then adds this extra `note` qualifier to represent the segments that make up the feature.

Why do they do that is beyond me, but I do not plan to emulate this behaviour. I’ll fix the parser so that we end up with properly ordered joins, so that all the information will be in the feature location (where it should be) without the need for an extra note.
Sounds good to me, thank you.
Great, thanks! @gouttegd, the only advantage that I see with having that additional note is that they include the name/label of each part of the main parent feature. In the case above, for example, `signal sequence`. Would it be possible to preserve that information in some way in some other way, as it could be useful?
As far as I know there is no provision in the `SeqRecord` model (or in the GenBank model for that matter) to represent "named sub features", so any solution we could come up with to keep that information would basically be a kludge of some sort.

Possible solution A: The "SnapGene method", where we generate a note to record everything we cannot record otherwise:

```
     CDS             complement(join(3081..3872,3873..3941))
                     /product="beta-lactamase"
                     /note="part 3873-3941: signal sequence"
```

That's definitely a kludge, and I am *very* reluctant to do that.

Possible solution B: Generate a distinct feature with its own `label`:

```
     CDS             complement(join(3081..3872,3873..3941))
                     /product="beta-lactamase"
                     [...]
     misc_feature    complement(3873..3941)
                     /label="signal sequence"
```

That's cleaner and actually less of a kludge. Would that solution be satisfying?

But really, to me it seems that SnapGene is abusing the concept of compound features by allowing each part to have a name/label. If a part of a compound feature is distinct enough to have its own label, then I believe it should really be a distinct feature, not a "sub feature".
I think the reason why SnapGene does it that way may be related to the aesthetics of displaying such compound features, see an example below not related to the feature I used nefore

![Untitled-1 copy](https://user-images.githubusercontent.com/2800360/124753663-8c928d80-df29-11eb-9cb1-1ab65d5a093d.png)

I would be fine with solution B. In that case, would you not want to add some additional info to the feature so that it's clear that it's part of another feature, for example

```
 CDS             complement(join(3081..3872,3873..3941))
                 /product="beta-lactamase"
                 [...]
 misc_feature    complement(3873..3941)
                 /label="signal sequence"
                 /product="beta-lactamase"
                 [...]
```

I don't really know whether adding that extra info would be a good idea or not. You know more about the GenBank model than I do, I'll let you decide.
OK for solution B. There will be a PR soon. :)

As for adding *something* that would allow to "attach" the added feature to the original compound feature: I am not keen on that since all I can foresee is complications no matter what is the *something* we choose to add.

A) We copy one or several qualifier(s) from the original compound feature (as you did in your example with `product`). Which qualifier(s) to copy? What if the original feature has no qualifiers? There is also the risk that a qualifier from the original feature may become misleading when applied to only a part of it (e.g., I think it would be wrong to say that the product of the signal sequence of the *bla* gene is the β-lactamase).

B) We add a `note` that reads like `This is part of feature X`. Where do we get the `X`? From the label of the original feature? Not all features have a label, and conversely there may be several features with the same label (as far as I know there is no rule saying that labels should be unique within a given sequence).

C) We add a `note` that reads like `This is part of the compound feature located in X..Y`. That would be the only always-applicable, non-ambiguous way of doing it. But then this is completely redundant with the information already provided in the feature location, so there's no point…
We had similar issues with trying to map the GFF nested features to the flat list (with only implicit links between gene and CDS etc) approach based on GenBank/EMBL files. We've not come back to revisit that - Brad Chapman did a lot of work in this area, but he and I have not had time or direct need to work on it.

The approach you've outlined sounds reasonable (but I have not looked at the raw representation in SnapGene format), again the co-location and sorting giving context and an implicit link.

Resolving patch:
diff --git a/Bio/SeqIO/SnapGeneIO.py b/Bio/SeqIO/SnapGeneIO.py
--- a/Bio/SeqIO/SnapGeneIO.py
+++ b/Bio/SeqIO/SnapGeneIO.py
@@ -144,12 +144,33 @@ def _parse_features_packet(length, data, record):
             strand = -1
 
         location = None
+        subparts = []
+        n_parts = 0
         for segment in feature.getElementsByTagName("Segment"):
+            if _get_attribute_value(segment, "type", "standard") == "gap":
+                continue
             rng = _get_attribute_value(segment, "range")
+            n_parts += 1
+            next_location = _parse_location(rng, strand, record)
             if not location:
-                location = _parse_location(rng, strand, record)
+                location = next_location
+            elif strand == -1:
+                # Reverse segments order for reverse-strand features
+                location = next_location + location
             else:
-                location = location + _parse_location(rng, strand, record)
+                location = location + next_location
+
+            name = _get_attribute_value(segment, "name")
+            if name:
+                subparts.append([n_parts, name])
+
+        if len(subparts) > 0:
+            # Add a "parts" qualifiers to represent "named subfeatures"
+            if strand == -1:
+                # Reverse segment indexes and order for reverse-strand features
+                subparts = reversed([[n_parts - i + 1, name] for i, name in subparts])
+            quals["parts"] = [";".join("{}:{}".format(i, name) for i, name in subparts)]
+
         if not location:
             raise ValueError("Missing feature location")
 
@@ -221,7 +242,6 @@ def _parse_primers_packet(length, data, record):
     0x0A: _parse_features_packet,
 }
 
-
 # Helper functions to process the XML data in
 # some of the segments
 


Test patch:
diff --git a/Tests/SnapGene/sample-d.dna b/Tests/SnapGene/sample-d.dna
Binary files a/Tests/SnapGene/sample-d.dna and b/Tests/SnapGene/sample-d.dna differ
diff --git a/Tests/SnapGene/sample-f.dna b/Tests/SnapGene/sample-f.dna
new file mode 100644
Binary files /dev/null and b/Tests/SnapGene/sample-f.dna differ
diff --git a/Tests/test_SeqIO_SnapGene.py b/Tests/test_SeqIO_SnapGene.py
--- a/Tests/test_SeqIO_SnapGene.py
+++ b/Tests/test_SeqIO_SnapGene.py
@@ -11,6 +11,7 @@
 from io import BytesIO
 
 from Bio import SeqIO
+from Bio.SeqFeature import CompoundLocation
 
 
 class TestSnapGene(unittest.TestCase):
@@ -23,35 +24,44 @@ class TestSnapGene(unittest.TestCase):
             "description": "Sample Sequence D",
             "length": 1000,
             "topology": "linear",
-            "date": datetime.datetime(2020, 9, 23, 0, 0),
+            "date": datetime.datetime(2021, 7, 7, 0, 0),
             "features": [
                 {
                     "type": "misc_binding",
                     "start": 499,
                     "end": 700,
                     "strand": 1,
-                    "label": "FeatureB",
+                    "label": ["FeatureB"],
+                    "note": ["Sample feature B"],
                 },
                 {
                     "type": "promoter",
                     "start": 49,
                     "end": 150,
                     "strand": 1,
-                    "label": "FeatureA",
+                    "label": ["FeatureA"],
+                    "note": ["Sample feature A"],
                 },
                 {
                     "type": "misc_feature",
                     "start": 700,
                     "end": 720,
                     "strand": 1,
-                    "label": "FeatureC",
+                    "label": ["FeatureC"],
+                    "note": [
+                        "Sample feature C, with explicit label",
+                        "Another note for sample feature C",
+                    ],
                 },
                 {
                     "type": "misc_feature",
                     "start": 720,
                     "end": 740,
                     "strand": 1,
-                    "label": "SampleFeatureD",
+                    "label": ["SampleFeatureD"],
+                    "note": [
+                        "Sample feature D, with a label different from the feature name"
+                    ],
                     "name": "FeatureD",
                 },
             ],
@@ -70,14 +80,53 @@ class TestSnapGene(unittest.TestCase):
                     "start": 399,
                     "end": 750,
                     "strand": -1,
-                    "label": "FeatureB",
+                    "label": ["FeatureB"],
                 },
                 {
                     "type": "rep_origin",
                     "start": 160,
                     "end": 241,
                     "strand": 1,
-                    "label": "FeatureA",
+                    "label": ["FeatureA"],
+                },
+            ],
+        },
+        "sample-f": {
+            "file": "SnapGene/sample-f.dna",
+            "name": "Sample",
+            "id": "Sample",
+            "description": "Sample Sequence F",
+            "length": 1000,
+            "date": datetime.datetime(2021, 7, 26, 0, 0),
+            "topology": "circular",
+            "features": [
+                {
+                    "type": "terminator",
+                    "start": 399,
+                    "end": 724,
+                    "strand": -1,
+                    "label": ["FeatureB"],
+                    "note": ["An example of a reverse-strand split feature"],
+                    "parts": ["1:subfeature1;3:subfeature3"],
+                    "segments": [
+                        {"start": 634, "end": 724},
+                        {"start": 516, "end": 634},
+                        {"start": 399, "end": 499},
+                    ],
+                },
+                {
+                    "type": "rep_origin",
+                    "start": 160,
+                    "end": 241,
+                    "strand": 1,
+                    "label": ["FeatureA"],
+                    "note": ["An example of a split feature"],
+                    "parts": ["2:subfeature2"],
+                    "segments": [
+                        {"start": 160, "end": 180},
+                        {"start": 187, "end": 207},
+                        {"start": 214, "end": 241},
+                    ],
                 },
             ],
         },
@@ -95,68 +144,87 @@ class TestSnapGene(unittest.TestCase):
                     "start": 0,
                     "end": 3941,
                     "strand": 1,
-                    "label": "SP6 promoter",
+                    "label": ["SP6 promoter"],
                 },
                 {
                     "type": "promoter",
                     "start": 1578,
                     "end": 1597,
                     "strand": -1,
-                    "label": "T7 promoter",
+                    "label": ["T7 promoter"],
                 },
                 {
                     "type": "promoter",
                     "start": 3474,
                     "end": 3579,
                     "strand": -1,
-                    "label": "AmpR promoter",
+                    "label": ["AmpR promoter"],
                 },
                 {
                     "type": "terminator",
                     "start": 1273,
                     "end": 1471,
                     "strand": 1,
-                    "label": "TEF terminator",
+                    "label": ["TEF terminator"],
                 },
                 {
                     "type": "promoter",
                     "start": 114,
                     "end": 458,
                     "strand": 1,
-                    "label": "TEF promoter",
+                    "label": ["TEF promoter"],
                 },
                 {
                     "type": "rep_origin",
                     "start": 1854,
                     "end": 2443,
                     "strand": -1,
-                    "label": "ori",
+                    "label": ["ori"],
                 },
                 {
                     "type": "CDS",
                     "start": 458,
                     "end": 1268,
                     "strand": 1,
-                    "label": "KanR",
+                    "label": ["KanR"],
                 },
                 {
                     "type": "CDS",
                     "start": 2613,
                     "end": 3474,
                     "strand": -1,
-                    "label": "AmpR",
+                    "label": ["AmpR"],
+                    "parts": ["1:signal sequence"],
+                    "segments": [
+                        {"start": 3405, "end": 3474},
+                        {"start": 2613, "end": 3405},
+                    ],
                 },
                 {
                     "type": "gene",
                     "start": 114,
                     "end": 1471,
                     "strand": 1,
-                    "label": "kanMX",
+                    "label": ["kanMX"],
                 },
             ],
         },
     }
 
+    def _check_multivalued_qualifier(self, qualifier, expected, actual):
+        if qualifier in expected:
+            for value in expected[qualifier]:
+                self.assertIn(value, actual.qualifiers[qualifier])
+
+    def _check_feature_segments(self, segments, feature):
+        self.assertIsInstance(feature.location, CompoundLocation)
+        self.assertEqual(len(segments), len(feature.location.parts))
+        for i in range(len(segments)):
+            segment = segments[i]
+            location = feature.location.parts[i]
+            self.assertEqual(segment["start"], location.start)
+            self.assertEqual(segment["end"], location.end)
+
     def test_read(self):
         """Read sample files."""
         for sample in self.sample_data.values():
@@ -176,11 +244,15 @@ def test_read(self):
                 self.assertEqual(exp_feat["start"], read_feat.location.start)
                 self.assertEqual(exp_feat["end"], read_feat.location.end)
                 self.assertEqual(exp_feat["strand"], read_feat.location.strand)
-                self.assertEqual(exp_feat["label"], read_feat.qualifiers["label"][0])
+                self._check_multivalued_qualifier("label", exp_feat, read_feat)
+                self._check_multivalued_qualifier("note", exp_feat, read_feat)
+                self._check_multivalued_qualifier("parts", exp_feat, read_feat)
                 if "name" in exp_feat:
                     self.assertEqual(exp_feat["name"], read_feat.qualifiers["name"][0])
                 else:
                     self.assertTrue("name" not in read_feat.qualifiers)
+                if "segments" in exp_feat:
+                    self._check_feature_segments(exp_feat["segments"], read_feat)
 
 
 class TestCorruptedSnapGene(unittest.TestCase):