Repository: cogent3/cogent3
Pull request #1700 (2024-01-28 20:24:46)

ENH: added new properties on SeqView
Plus two additional commits to deal with the impact of having no access to my GitHub repo for 14 days.

Ignore the merge and the commit related to `Alignment.get_degapped_relative_to()`.


Patch:
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 0cf38c138..1189836da 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -142,7 +142,7 @@ class SequenceI(object):
             version=__version__,
         )
         if hasattr(self, "annotation_offset"):
-            offset = self._seq.offset + self._seq.start
+            offset = self._seq.parent_start
             data.update(dict(annotation_offset=offset))
 
         if (
@@ -855,7 +855,7 @@ class Sequence(SequenceI):
             int: The offset between annotation coordinates and sequence coordinates.
         """
 
-        return self._seq.offset + self._seq.start
+        return self._seq.parent_start
 
     @annotation_offset.setter
     def annotation_offset(self, value):
@@ -1888,7 +1888,15 @@ def _input_vals_neg_step(seqlen, start, stop, step):
 class SeqView:
     __slots__ = ("seq", "start", "stop", "step", "_offset")
 
-    def __init__(self, seq, *, start: int = None, stop: int = None, step: int = None):
+    def __init__(
+        self,
+        seq,
+        *,
+        start: int = None,
+        stop: int = None,
+        step: int = None,
+        offset: int = 0,
+    ):
         if step == 0:
             raise ValueError("step cannot be 0")
         step = 1 if step is None else step
@@ -1898,7 +1906,7 @@ class SeqView:
         self.seq = seq
         self.start = start
         self.stop = stop
-        self._offset = 0
+        self._offset = offset
         self.step = step
 
     @property
@@ -1909,6 +1917,41 @@ class SeqView:
     def offset(self, value: int):
         self._offset = value or 0
 
+    @property
+    def parent_start(self) -> int:
+        """returns the start on the parent plus strand
+
+        Returns
+        -------
+        offset + start, taking into account whether reversed. Result
+        is positive.
+        """
+        if self.reverse:
+            # self.stop becomes the start, self.stop will be negative
+            assert self.stop < 0, "expected stop on reverse strand SeqView < 0"
+            start = self.stop + len(self.seq) + 1
+        else:
+            start = self.start
+
+        return self.offset + start
+
+    @property
+    def parent_stop(self) -> int:
+        """returns the stop on the parent plus strand
+
+        Returns
+        -------
+        offset + stop, taking into account whether reversed. Result
+        is positive.
+        """
+        if self.reverse:
+            # self.start becomes the stop, self.start will be negative
+            assert self.start < 0, "expected start on reverse strand SeqView < 0"
+            stop = self.start + len(self.seq) + 1
+        else:
+            stop = self.stop
+        return self.offset + stop
+
     @property
     def reverse(self):
         return self.step < 0
@@ -1924,8 +1967,8 @@ class SeqView:
 
         Returns
         -------
-        the absolute index with respect to the coordinates of the sequence's annotations
-
+        the absolute index with respect to the coordinates of the self
+        including offset
         """
 
         if rel_index < 0:
@@ -2049,6 +2092,7 @@ class SeqView:
             start=start,
             stop=min(self.stop, stop),
             step=self.step * step,
+            offset=self.offset,
         )
 
     def _get_forward_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
@@ -2073,6 +2117,7 @@ class SeqView:
             start=start,
             stop=max(self.stop, stop),
             step=self.step * step,
+            offset=self.offset,
         )
 
     def _get_reverse_slice(self, segment, step):
@@ -2126,6 +2171,7 @@ class SeqView:
             start=start,
             stop=max(stop, self.start - len(self.seq) - 1),
             step=self.step * step,
+            offset=self.offset,
         )
 
     def _get_reverse_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
@@ -2162,16 +2208,26 @@ class SeqView:
             start=start,
             stop=stop,
             step=self.step * step,
+            offset=self.offset,
         )
 
     def __getitem__(self, segment):
         if _is_int(segment):
             start, stop, step = self._get_index(segment)
-            return self.__class__(self.seq, start=start, stop=stop, step=step)
+            return self.__class__(
+                self.seq,
+                start=start,
+                stop=stop,
+                step=step,
+                offset=self.offset,
+            )
 
         if len(self) == 0:
             return self
 
+        if segment.start is not None and segment.start == segment.stop:
+            return _zero_slice
+
         slice_step = 1 if segment.step is None else segment.step
 
         if slice_step > 0:
@@ -2207,7 +2263,10 @@ class SeqView:
 
     def __repr__(self) -> str:
         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}, stop={self.stop}, step={self.step})"
+        return (
+            f"{self.__class__.__name__}(seq={seq!r}, start={self.start}, "
+            f"stop={self.stop}, step={self.step}, offset={self.offset})"
+        )
 
     def to_rich_dict(self):
         # get the current state
@@ -2218,21 +2277,19 @@ class SeqView:
         if self.reverse:
             adj = len(self.seq) + 1
             start, stop = self.stop + adj, self.start + adj
-            offset = self.offset + start
         else:
             start, stop = self.start, self.stop
-            offset = self.offset + self.start
 
-        data["offset"] = offset
         data["init_args"]["seq"] = self.seq[start:stop]
+        data["init_args"]["offset"] = self.parent_start
         return data
 
     @classmethod
     def from_rich_dict(cls, data: dict):
         init_args = data.pop("init_args")
-        offset = data.pop("offset", 0)
+        if "offset" in data:
+            init_args["offset"] = data.pop("offset")
         sv = cls(**init_args)
-        sv.offset = offset
         return sv
 
 


Test patch:
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index af034fa98..17a026c7b 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1835,19 +1835,25 @@ def test_seqview_repr():
     # case 1: Short sequence, defaults
     seq = "ACGT"
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1)"
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0)"
     assert repr(view) == expected
 
     # case 2: Long sequence
     seq = "ACGT" * 10
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1, offset=0)"
     assert repr(view) == expected
 
     # case 3: 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)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2, offset=0)"
+    assert repr(view) == expected
+
+    # case 4: offset
+    seq = "ACGT"
+    view = SeqView(seq, offset=5)
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=5)"
     assert repr(view) == expected
 
 
@@ -2342,9 +2348,6 @@ def test_seqview_to_rich_dict():
     sv = SeqView(seq=parent)
     plus = sv.to_rich_dict()
     minus = sv[::-1].to_rich_dict()
-    for attr in ("type", "offset", "version"):
-        assert plus.pop(attr) == minus.pop(attr)
-
     plus = plus.pop("init_args")
     minus = minus.pop("init_args")
     assert plus.pop("seq") == minus.pop("seq")
@@ -2378,7 +2381,78 @@ def test_sliced_seqview_rich_dict(reverse):
         sv = sv[::-1]
     rd = sv.to_rich_dict()
     assert rd["init_args"]["seq"] == parent[sl]
-    assert rd["offset"] == 2
+    assert rd["init_args"]["offset"] == 2
+
+
+@pytest.mark.parametrize(
+    "sl",
+    (
+        slice(2, 5, 1),  # positive indices, positive step
+        slice(-8, -5, 1),  # negative indices, positive step
+        slice(4, 1, -1),  # positive indices, negative step
+        slice(-6, -9, -1),  # negative indices, negative step
+    ),
+)
+@pytest.mark.parametrize("offset", (4, 0))
+def test_parent_start_stop(sl, offset):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = "234" if sl.step > 0 else "432"
+    sv = SeqView(data)
+    sv.offset = offset
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (2 + offset, 5 + offset)
+
+
+@pytest.mark.parametrize(
+    "sl",
+    (
+        slice(None, None, 1),  # slice whole sequence plus strand
+        slice(None, None, -1),  # slice whole sequence minus strand
+    ),
+)
+def test_parent_start_stop_limits(sl):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = data[sl]
+    sv = SeqView(data)
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (0, 10)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_parent_start_stop_empty(rev):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = ""
+    sv = SeqView(data)
+    sv = sv[0 : 0 : -1 if rev else 1]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (0, 0)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+@pytest.mark.parametrize("index", range(9))
+def test_parent_start_stop_singletons(index, rev):
+    data = "0123456789"
+    start, stop = (-(10 - index), -(10 - index + 1)) if rev else (index, index + 1)
+    sl = slice(start, stop, -1 if rev else 1)
+    # check our slice matches the expectation for rest of test
+    expect = data[sl]
+    sv = SeqView(data)
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (index, index + 1)
 
 
 def test_get_drawable(DATA_DIR):