Repository: cogent3/cogent3 (python)

Issue:
add attribute to `SeqView` to keep track of the original seqid
The problem: given a sequence with annotations, if it is renamed, the connection to the underlying annotations will be broken.

Solving this requires keeping track of the "parent" name, which we are calling the seqid, via the `SeqView` class. 

The first step is to add `seqid` as an argument to the constructor of `SeqView`:

```py
class SeqView:
    def __init__(
        self,
        seq,
        *,
        start: int = None,
        stop: int = None,
        step: int = None,
        offset: int = 0,
        seqid: str = None,
    ):
```
and adding `_seqid` as an attribute of the class 

```py 
self._seqid = seqid
```

Then following class methods will need to be modified to cope:

- `to_rich_dict` 
- `from_rich_dict`
- `copy`

@GavinHuttley 

The expected behaviour is that when a `SeqView` instance is created, and is attached to a `Sequence` with a name, upon initialisation the `seqid` will be assigned the value of the `name`?

Pull request:
DEV: added seqid arg to SeqView, fixes #1708

Patch:
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index bc4fbf996..8b79814e5 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -1910,7 +1910,7 @@ def _input_vals_neg_step(seqlen, start, stop, step):
 
 
 class SeqView:
-    __slots__ = ("seq", "start", "stop", "step", "_offset")
+    __slots__ = ("seq", "start", "stop", "step", "_offset", "_seqid")
 
     def __init__(
         self,
@@ -1920,6 +1920,7 @@ class SeqView:
         stop: int = None,
         step: int = None,
         offset: int = 0,
+        seqid: str = None,
     ):
         if step == 0:
             raise ValueError("step cannot be 0")
@@ -1930,8 +1931,9 @@ class SeqView:
         self.seq = seq
         self.start = start
         self.stop = stop
-        self._offset = offset
         self.step = step
+        self._offset = offset
+        self._seqid = seqid
 
     @property
     def offset(self) -> int:
@@ -1941,6 +1943,10 @@ class SeqView:
     def offset(self, value: int):
         self._offset = value or 0
 
+    @property
+    def seqid(self) -> str:
+        return self._seqid
+
     @property
     def parent_start(self) -> int:
         """returns the start on the parent plus strand
@@ -2117,6 +2123,7 @@ class SeqView:
             stop=min(self.stop, stop),
             step=self.step * step,
             offset=self.offset,
+            seqid=self.seqid,
         )
 
     def _get_forward_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
@@ -2142,6 +2149,7 @@ class SeqView:
             stop=max(self.stop, stop),
             step=self.step * step,
             offset=self.offset,
+            seqid=self.seqid,
         )
 
     def _get_reverse_slice(self, segment, step):
@@ -2196,6 +2204,7 @@ class SeqView:
             stop=max(stop, self.start - len(self.seq) - 1),
             step=self.step * step,
             offset=self.offset,
+            seqid=self.seqid,
         )
 
     def _get_reverse_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
@@ -2233,6 +2242,7 @@ class SeqView:
             stop=stop,
             step=self.step * step,
             offset=self.offset,
+            seqid=self.seqid,
         )
 
     def __getitem__(self, segment):
@@ -2244,6 +2254,7 @@ class SeqView:
                 stop=stop,
                 step=step,
                 offset=self.offset,
+                seqid=self.seqid,
             )
 
         if segment.start is segment.stop is segment.step is None:
@@ -2274,7 +2285,12 @@ class SeqView:
         new_seq = self.seq.replace(old, new)
         if len(old) == len(new):
             return self.__class__(
-                new_seq, start=self.start, stop=self.stop, step=self.step
+                new_seq,
+                start=self.start,
+                stop=self.stop,
+                step=self.step,
+                offset=self.offset,
+                seqid=self.seqid,
             )
 
         return self.__class__(new_seq)
@@ -2292,7 +2308,8 @@ class SeqView:
         seq = f"{self.seq[:10]}...{self.seq[-5:]}" if len(self.seq) > 15 else self.seq
         return (
             f"{self.__class__.__name__}(seq={seq!r}, start={self.start}, "
-            f"stop={self.stop}, step={self.step}, offset={self.offset})"
+            f"stop={self.stop}, step={self.step}, offset={self.offset}, "
+            f"seqid={self.seqid!r})"
         )
 
     def to_rich_dict(self):
@@ -2309,6 +2326,7 @@ class SeqView:
 
         data["init_args"]["seq"] = self.seq[start:stop]
         data["init_args"]["offset"] = self.parent_start
+        data["init_args"]["seqid"] = self.seqid
         return data
 
     @classmethod
@@ -2335,6 +2353,7 @@ class SeqView:
                 stop=self.stop,
                 step=self.step,
                 offset=self.offset,
+                seqid=self.seqid,
             )
         return self.from_rich_dict(self.to_rich_dict())
 


Test patch:
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 99f4cd146..755120cb3 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1832,28 +1832,34 @@ def test_seqview_remove_gaps():
 
 
 def test_seqview_repr():
-    # case 1: Short sequence, defaults
+    # Short sequence, defaults
     seq = "ACGT"
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0)"
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid=None)"
     assert repr(view) == expected
 
-    # case 2: Long sequence
+    # Long sequence
     seq = "ACGT" * 10
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1, offset=0)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1, offset=0, seqid=None)"
     assert repr(view) == expected
 
-    # case 3: Non-zero start, stop, and step values
+    # Non-zero start, stop, and step values
     seq = "ACGT" * 10
     view = SeqView(seq, start=5, stop=35, step=2)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2, offset=0)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2, offset=0, seqid=None)"
     assert repr(view) == expected
 
-    # case 4: offset
+    # offset
     seq = "ACGT"
     view = SeqView(seq, offset=5)
-    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=5)"
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=5, seqid=None)"
+    assert repr(view) == expected
+
+    # seqid
+    seq = "ACGT"
+    view = SeqView(seq, seqid="seq1")
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid='seq1')"
     assert repr(view) == expected
 
 
@@ -2613,3 +2619,39 @@ def test_parent_coordinates(rev):
         seq = seq.rc()
 
     assert seq.parent_coordinates() == (0, 0, 1)
+
+
+def test_seqview_seqid():
+    sv = SeqView("ACGGTGGGAC")
+    assert sv.seqid is None
+
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    assert sv.seqid == "seq1"
+
+
+def test_seqview_rich_dict_round_trip_seqid():
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == "seq1"
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == "seq1"
+
+    sv = SeqView("ACGGTGGGAC")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == None
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == None
+
+
+def test_seqview_slice_propagates_seqid():
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    sliced_sv = sv[1:8:2]
+    assert sliced_sv.seqid == "seq1"
+
+    copied_sv = sliced_sv.copy(sliced=False)
+    assert copied_sv.seqid == "seq1"
+
+    copied_sliced_sv = sliced_sv.copy(sliced=True)
+    assert copied_sliced_sv.seqid == "seq1"