Repository: cogent3/cogent3
Pull request #1876 (2024-05-24 05:20:04)

DEV: Update `SeqDataView` with `SliceRecordsABC` 
Draft PR to get current progress reviewed before going further. Mainly changes to `SeqData` and `SeqDataView` to be compatible with the `SeqView`/`SliceRecordABC` refactor.

A preliminary attempt at adding `SeqData._set_seq_maker`.

Lots of tests required for `SeqData` after copying over `SeqView` methods, and for `SeqData.__getitem__` when `self._seq_maker` is provided.


Patch:
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
new file mode 100644
index 000000000..136899823
--- /dev/null
+++ b/src/cogent3/core/new_alignment.py
@@ -0,0 +1,425 @@
+from __future__ import annotations
+
+from dataclasses import InitVar, dataclass, field
+from functools import singledispatch, singledispatchmethod
+from typing import Callable, Iterator, Optional, Union
+
+import numpy
+
+from cogent3 import get_moltype
+from cogent3._version import __version__
+from cogent3.core.alphabet import CharAlphabet
+from cogent3.core.location import IndelMap
+from cogent3.core.moltype import MolType
+from cogent3.core.sequence import (
+    Sequence,
+    SliceRecordABC,
+    _input_vals_neg_step,
+    _input_vals_pos_step,
+)
+from cogent3.util.misc import get_object_provenance
+
+
+SeqTypes = Union[str, bytes, numpy.ndarray]
+
+
+@singledispatch
+def seq_index(seq: SeqTypes, alphabet: CharAlphabet) -> numpy.ndarray:
+    raise NotImplementedError(
+        f"{seq_index.__name__} not implemented for type {type(seq)}"
+    )
+
+
+@seq_index.register
+def _(seq: str, alphabet: CharAlphabet) -> numpy.ndarray:
+    return alphabet.to_indices(seq)
+
+
+@seq_index.register
+def _(seq: bytes, alphabet: CharAlphabet) -> numpy.ndarray:
+    return alphabet.to_indices(seq)
+
+
+@seq_index.register
+def _(seq: numpy.ndarray, alphabet: CharAlphabet) -> numpy.ndarray:
+    return seq.astype(alphabet.array_type)
+
+
+@singledispatch
+def validate_names(correct_names: Union[dict, tuple, list], names: tuple) -> tuple:
+    """
+    Helper to check that names passed as args match sequence names.
+    dict (data) for constructor; tuple for SeqData instance
+    """
+    raise NotImplementedError(
+        f"validate_names not implemented for type {type(correct_names)}"
+    )
+
+
+@validate_names.register
+def _(correct_names: dict, names: tuple) -> tuple:
+    keys = correct_names.keys()
+    if names is None:
+        return tuple(keys)
+    if set(names) == set(keys):
+        return names
+    raise ValueError("names do not match dictionary keys")
+
+
+def _names_list_tuple(correct_names: Union[tuple, list], names: tuple):
+    """List and tuples have the same implementation for dispatch"""
+    if names is None:
+        return correct_names
+    if set(names) <= set(correct_names):
+        return tuple(names)
+    raise ValueError("some names do not match")
+
+
+@validate_names.register
+def _(correct_names: tuple, names: tuple) -> tuple:
+    return _names_list_tuple(correct_names, names)
+
+
+@validate_names.register
+def _(correct_names: list, names: tuple) -> tuple:
+    return _names_list_tuple(correct_names, names)
+
+
+class SeqDataView(SliceRecordABC):
+    """
+    A view class for SeqData, providing properties for different
+    representations.
+
+    self.seq is a SeqData() instance, but other properties are a reference to a
+    single seqid only.
+
+    Example
+    -------
+    data = {"seq1": "ACGT", "seq2": "GTTTGCA"}
+    sd = SeqData(data=data)
+    sdv = sd.get_seq_view(seqid="seq1")
+    """
+
+    __slots__ = ("seq", "start", "stop", "step", "_offset", "_seqid", "_seq_len")
+
+    def __init__(
+        self,
+        *,
+        seq: SeqData,
+        start: Optional[int] = None,
+        stop: Optional[int] = None,
+        step: Optional[int] = None,
+        offset: int = 0,
+        seqid: Optional[str] = None,
+        seq_len: Optional[int] = None,
+    ):
+        if step == 0:
+            raise ValueError("step cannot be 0")
+        step = 1 if step is None else step
+
+        self._seq_len = self._checked_seq_len(seq_len)
+        func = _input_vals_pos_step if step > 0 else _input_vals_neg_step
+        start, stop, step = func(self._seq_len, start, stop, step)
+        self.seq = seq
+        self.start = start
+        self.stop = stop
+        self.step = step
+        self._offset = offset
+        self._seqid = seqid
+
+    def _checked_seq_len(self, seq_len: int) -> int:
+        assert seq_len is not None
+        return seq_len
+
+    @property
+    def _zero_slice(self):
+        return self.__class__(
+            seq=self.seq, seqid=self.seqid, seq_len=self.seq_len, start=0, stop=0
+        )
+
+    @property
+    def seqid(self) -> str:
+        return self._seqid
+
+    @property
+    def seq_len(self) -> int:
+        return self._seq_len
+
+    def _get_init_kwargs(self):
+        return {"seq": self.seq, "seqid": self.seqid}
+
+    @property
+    def str_value(self) -> str:
+        raw = self.seq.get_seq_str(
+            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
+        )
+        return raw if self.step == 1 else raw[:: self.step]
+
+    @property
+    def array_value(self) -> numpy.ndarray:
+        raw = self.seq.get_seq_array(
+            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
+        )
+        return raw if self.step == 1 else raw[:: self.step]
+
+    @property
+    def bytes_value(self) -> bytes:
+        raw = self.seq.get_seq_bytes(
+            seqid=self.seqid, start=self.parent_start, stop=self.parent_stop
+        )
+        return raw if self.step == 1 else raw[:: self.step]
+
+    def __str__(self):
+        return self.str_value
+
+    def __array__(self):
+        return self.array_value
+
+    def __bytes__(self):
+        return self.bytes_value
+
+    def __repr__(self) -> str:
+        seq = f"{self[:10]!s}...{self[-5:]}" if len(self) > 15 else str(self)
+        return (
+            f"{self.__class__.__name__}(seq={seq}, start={self.start}, "
+            f"stop={self.stop}, step={self.step}, offset={self.offset}, "
+            f"seqid={self.seqid!r}, seq_len={self.seq_len})"
+        )
+
+    def to_rich_dict(self):
+        # get the current state
+        data = {"type": get_object_provenance(self), "version": __version__}
+        data["init_args"] = self._get_init_kwargs()
+        # since we will truncate the seq, we don't need start, stop,
+        # step is sufficient
+        data["init_args"]["step"] = self.step
+        if self.is_reversed:
+            adj = self.seq_len + 1
+            start, stop = self.stop + adj, self.start + adj
+        else:
+            start, stop = self.start, self.stop
+
+        data["init_args"]["seq"] = str(self[start:stop])
+        data["init_args"]["offset"] = int(self.parent_start)
+        return data
+
+    @classmethod
+    def from_rich_dict(cls, data: dict):
+        init_args = data.pop("init_args")
+        if "offset" in data:
+            init_args["offset"] = data.pop("offset")
+        return cls(**init_args)
+
+    def copy(self, sliced: bool = False):
+        """returns copy
+
+        Parameters
+        ----------
+        sliced
+            if True, the underlying sequence is truncated and the start/stop
+            adjusted
+        """
+        if not sliced:
+            return self.__class__(
+                seq=self.seq,
+                start=self.start,
+                stop=self.stop,
+                step=self.step,
+                offset=self.offset,
+                seqid=self.seqid,
+                seq_len=self.seq_len,
+            )
+        return self.from_rich_dict(self.to_rich_dict())
+
+
+class SeqData:
+    __slots__ = ("_data", "_alphabet", "_names", "_make_seq")
+
+    def __init__(
+        self,
+        data: dict[str, SeqTypes],
+        alphabet: CharAlphabet,
+        names: Optional[Union[tuple[str], None]] = None,
+        make_seq: Optional[type] = None,
+    ):
+        self._alphabet = alphabet
+        self._names = validate_names(data, names)
+        self._make_seq = make_seq
+        # convert from string to array of uint8
+        self._data: dict[str, numpy.ndarray] = {
+            k: seq_index(v, self._alphabet) for k, v in data.items()
+        }
+
+    @property
+    def names(self) -> Iterator[str]:
+        yield from self._names
+
+    @property
+    def make_seq(self) -> Union[SeqDataView, Sequence]:
+        return self._make_seq
+
+    @make_seq.setter
+    def make_seq(self, make_seq: Callable) -> None:
+        self._make_seq = make_seq
+
+    @singledispatchmethod
+    def __getitem__(self, index: Union[str, int]) -> SeqDataView:
+        raise NotImplementedError(f"__getitem__ not implemented for {type(index)}")
+
+    @__getitem__.register
+    def _(self, index: str) -> SeqDataView:
+        sdv = self.get_seq_view(seqid=index)
+        return sdv if self._make_seq is None else self.make_seq(sdv, seqid=index)
+
+    @__getitem__.register
+    def _(self, index: int) -> SeqDataView:
+        return self[self._names[index]]
+
+    def get_seq_array(
+        self, *, seqid: str, start: int = None, stop: int = None
+    ) -> numpy.ndarray:
+        return self._data[seqid][start:stop]
+
+    def get_seq_str(self, *, seqid: str, start: int = None, stop: int = None) -> str:
+        return self._alphabet.from_indices(
+            self.get_seq_array(seqid=seqid, start=start, stop=stop)
+        )
+
+    def get_seq_bytes(
+        self, *, seqid: str, start: int = None, stop: int = None
+    ) -> bytes:
+        return self.get_seq_str(seqid=seqid, start=start, stop=stop).encode("utf8")
+
+    def get_seq_view(self, seqid: str) -> SeqDataView:
+        seq_len = len(self._data[seqid])
+        return SeqDataView(seq=self, seqid=seqid, seq_len=seq_len)
+
+
+@singledispatch
+def seq_to_gap_coords(
+    seq: Union[str, numpy.ndarray], moltype: MolType
+) -> tuple[str, IndelMap]:
+    """
+    Takes a sequence with (or without) gaps and returns an ungapped sequence
+    and records the position and length of gaps in the original parent sequence
+    """
+    raise NotImplementedError(f"{seq} not implemented for type {type(seq)}")
+
+
+@seq_to_gap_coords.register
+def _(seq: str, moltype: MolType) -> tuple[str, IndelMap]:
+    seq = moltype.make_seq(seq)
+    indel_map, ungapped_seq = seq.parse_out_gaps()
+
+    if indel_map.num_gaps == 0:
+        return str(ungapped_seq), numpy.array([], dtype=int)
+
+    return str(ungapped_seq), indel_map
+
+
+@seq_to_gap_coords.register
+def _(seq: numpy.ndarray, moltype: MolType) -> tuple[str, IndelMap]:
+    gap_char = moltype.alphabets.degen_gapped.index(moltype.gap)
+    # Create boolean array of gaps to get ungapped
+    gaps_bool = seq == gap_char
+    ungapped = seq[~gaps_bool]
+
+    # no gaps in seq
+    if numpy.array_equal(ungapped, seq):
+        return ungapped, numpy.array([], dtype=int)
+
+    parent_len = len(seq)
+    in_gap = False
+    parent_coords = []
+    start = 0
+    for i, gapped in enumerate(gaps_bool):
+        if gapped and not in_gap:
+            start = i
+            in_gap = True
+        if not gapped and in_gap:
+            parent_coords.append([start, i])
+            in_gap = False
+        if gapped and i == parent_len - 1:
+            # End of the sequence
+            parent_coords.append([start, i + 1])
+
+    # Format for IndelMap
+    parent_coords = numpy.array(parent_coords)
+    gap_lengths = numpy.array([end - start for start, end in parent_coords])
+    cum_lengths = gap_lengths.cumsum()
+    # Get gap start positions in sequence coords
+    gap_pos = parent_coords.T[0] - numpy.append(0, cum_lengths[:-1, numpy.newaxis])
+
+    indel_map = IndelMap(
+        gap_pos=gap_pos, cum_gap_lengths=cum_lengths, parent_length=parent_len
+    )
+
+    return ungapped, indel_map
+
+
+class AlignedDataView(SeqDataView):
+    """
+    Example
+    -------
+    data = {"seq1": "ACGT", "seq2": "GTTTGCA"}
+    ad = AlignedData.from_strings(data)
+    adv = sd.get_aligned_view(seqid="seq1")
+    """
+
+
+@dataclass
+class AlignedData:
+    # Look out for any overlaps with SeqData
+    seqs: Optional[dict[str, numpy.ndarray]] = None
+    gaps: Optional[dict[str, numpy.ndarray]] = None
+    _moltype: MolType = field(init=False)
+    _names: tuple[str] = field(init=False)
+    _alpha: CharAlphabet = field(init=False)
+    align_len: int = 0
+    moltype: InitVar[Union[str, None]] = "dna"
+    names: InitVar[Union[tuple[str], None]] = None
+
+    def __post_init__(self, moltype, names):
+        self._moltype = get_moltype(moltype)
+        self._alpha = self._moltype.alphabets.degen_gapped
+        self._names = validate_names(self.seqs, names)
+        self.seqs = {k: seq_index(v, self._alpha) for k, v in self.seqs.items()}
+
+    @classmethod
+    def from_gapped_seqs(
+        cls,
+        data: dict[str, Union[str, numpy.ndarray]],
+        moltype: Union[str, MolType] = "dna",
+        names: Optional[tuple[str]] = None,
+    ):
+        """
+        Convert dict of {"seq_name": "seq"} to two dicts for seqs and gaps
+        """
+        seq_lengths = {len(v) for v in data.values()}
+        if len(seq_lengths) != 1:
+            raise ValueError("All sequence lengths must be the same.")
+
+        align_len = seq_lengths.pop()
+        moltype = get_moltype(moltype)
+
+        seqs = {}
+        gaps = {}
+        for name, seq in data.items():
+            seqs[name], gaps[name] = seq_to_gap_coords(seq, moltype)
+
+        names = validate_names(seqs, names)
+
+        return cls(
+            seqs=seqs,
+            gaps=gaps,
+            moltype=moltype,
+            names=names,
+            align_len=align_len,
+        )
+
+    def get_aligned_view(self, seqid: str) -> AlignedDataView:
+        # Need to revisit what the variable is called i.e. parent_length
+        return AlignedDataView(seq=self, seqid=seqid, seq_len=self.align_len)
+
+    def get_gaps(self, seqid: str) -> numpy.ndarray:
+        return self.gaps[seqid]
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 831a21bec..80d73bafb 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -2054,6 +2054,9 @@ class SliceRecordABC(ABC):
 
         return rel_pos
 
+    def __len__(self):
+        return abs((self.start - self.stop) // self.step)
+
     def __getitem__(self, segment: Union[int, slice]):
         kwargs = self._get_init_kwargs()
 
@@ -2320,6 +2323,7 @@ class SeqView(SliceRecordABC):
         self.step = step
         self._offset = offset
         self._seqid = seqid
+
         if seq_len is not None and seq_len != len(seq):
             raise AssertionError(f"{seq_len} != {len(self.seq)})")
         self._seq_len = seq_len or len(self.seq)
@@ -2358,9 +2362,6 @@ class SeqView(SliceRecordABC):
 
         return self.__class__(seq=new_seq)
 
-    def __len__(self):
-        return abs((self.start - self.stop) // self.step)
-
     def __iter__(self):
         return iter(self.value)
 
@@ -2397,8 +2398,7 @@ class SeqView(SliceRecordABC):
         init_args = data.pop("init_args")
         if "offset" in data:
             init_args["offset"] = data.pop("offset")
-        sv = cls(**init_args)
-        return sv
+        return cls(**init_args)
 
     def copy(self, sliced: bool = False):
         """returns copy


Test patch:
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
new file mode 100644
index 000000000..3fb408cee
--- /dev/null
+++ b/tests/test_core/test_new_alignment.py
@@ -0,0 +1,441 @@
+import numpy as numpy
+import pytest
+
+from cogent3 import Sequence, get_moltype
+from cogent3._version import __version__
+from cogent3.core.alphabet import CharAlphabet
+from cogent3.core.new_alignment import (
+    AlignedData,
+    AlignedDataView,
+    SeqData,
+    SeqDataView,
+    seq_index,
+    seq_to_gap_coords,
+    validate_names,
+)
+
+
+@pytest.fixture
+def seq1():
+    return "ACTG"
+
+
+@pytest.fixture
+def simple_dict():
+    return dict(seq1="ACGT", seq2="GTTTGCA")
+
+
+@pytest.fixture
+def simple_dict_arr():
+    return dict(seq1=numpy.array([2, 1, 3, 0]), seq2=numpy.array([3, 0, 0, 0, 3, 1, 2]))
+
+
+@pytest.fixture
+def alpha():
+    moltype = get_moltype("dna")
+    return moltype.alphabets.degen_gapped
+
+
+@pytest.fixture
+def sd_demo(simple_dict: dict[str, str], alpha):
+    return SeqData(data=simple_dict, alphabet=alpha)
+
+
+@pytest.fixture
+def int_arr():
+    return numpy.arange(17, dtype=numpy.uint8)
+
+
+@pytest.fixture
+def sdv_s2(sd_demo: SeqData) -> SeqDataView:
+    return sd_demo.get_seq_view(seqid="seq2")
+
+
+@pytest.fixture
+def aligned_dict():
+    return dict(seq1="ACG--T", seq2="-CGAAT")
+
+
+@pytest.fixture
+def ad_demo(aligned_dict: dict[str, str]):
+    return AlignedData.from_gapped_seqs(aligned_dict)
+
+
+def test_seqdata_default_attributes(sd_demo: SeqData):
+    assert sd_demo._names == ("seq1", "seq2")
+    assert isinstance(sd_demo._alphabet, CharAlphabet)
+
+
+def test_seqdata_seq_if_str(seq1: str, alpha):
+    with pytest.raises(NotImplementedError):
+        SeqData(seq1, alphabet=alpha)
+    # assert SeqData(data)._names != ("A", "C", "T", "G")
+
+
+def test_validate_names_dict_happy(simple_dict):
+    got = validate_names(simple_dict, None)
+    assert got == ("seq1", "seq2")
+    got = validate_names(simple_dict, names=("seq2", "seq1"))
+    assert got == ("seq2", "seq1")
+
+
+@pytest.mark.parametrize(
+    "bad_names", [("bad"), ("bad",), ("bad2", "bad1"), ("seq1",), "seq1"]
+)
+def test_validate_names_dict_bad(simple_dict, bad_names):
+    with pytest.raises(ValueError):
+        validate_names(simple_dict, bad_names)
+
+
+@pytest.mark.parametrize(
+    "names", (["seq2"], ["seq2", "seq1"], ("seq2",), ("seq2", "seq1"))
+)
+def test_names_tuple_list_happy(names, sd_demo):
+    correct_names = sd_demo._names
+    got = validate_names(correct_names, names)
+    assert got == tuple(names)
+
+
+@pytest.mark.parametrize("bad_names", [("bad"), ("bad",), ("bad2", "bad1"), "seq1"])
+def test_names_tuple_list_bad(bad_names, sd_demo):
+    correct_names = sd_demo._names
+    with pytest.raises(ValueError):
+        validate_names(correct_names, bad_names)
+
+
+@pytest.mark.parametrize(
+    "bad_names", [("bad"), ("bad",), ("bad2", "bad1"), ("seq1",), "seq1"]
+)
+def test_names_init(simple_dict, alpha, bad_names):
+    sd = SeqData(simple_dict, alphabet=alpha)
+    assert sd._names == ("seq1", "seq2")
+    sd = SeqData(simple_dict, alphabet=alpha, names=("seq2", "seq1"))
+    assert sd._names == ("seq2", "seq1")
+
+    with pytest.raises(ValueError):
+        SeqData(simple_dict, alphabet=alpha, names=bad_names)
+
+
+@pytest.mark.parametrize("seqid", ["seq1", "seq2"])
+def test_seqdataview_repr_default(sd_demo: SeqData, seqid: str):
+    seq = sd_demo.get_seq_str(seqid=seqid)
+    seq_len = len(seq)
+    expect = f"SeqDataView(seq={seq}, start=0, stop={seq_len}, step=1, offset=0, seqid='{seqid}', seq_len={seq_len})"
+    got = sd_demo.get_seq_view(seqid)
+    assert repr(got) == expect
+
+
+def test_seqdataview_repr_default_long(alpha):
+    longseq = "CGATCGTAGTACGTGTCAAGTCTGAC"
+    seq_len = len(longseq)
+    trunc = f"{longseq[:10]}...{longseq[-5:]}"
+    expect = f"SeqDataView(seq={trunc}, start=0, stop={seq_len}, step=1, offset=0, seqid='long', seq_len={seq_len})"
+
+    d = {"long": longseq}
+    sd = SeqData(d, alphabet=alpha)
+    got = sd.get_seq_view(seqid="long")
+    assert repr(got) == expect
+
+
+@pytest.mark.parametrize("seqid", ["seq1", "seq2"])
+def test_seqdataview_to_rich_dict(sd_demo: SeqData, seqid):
+    seq = sd_demo.get_seq_str(seqid=seqid)
+    expect = {
+        "type": "cogent3.core.new_alignment.SeqDataView",
+        "version": __version__,
+        "init_args": {"seq": seq, "seqid": seqid, "step": 1, "offset": 0},
+    }
+
+    sdv = sd_demo.get_seq_view(seqid)
+    got = sdv.to_rich_dict()
+    assert expect == got
+
+
+@pytest.mark.parametrize("seqid", ["seq1", "seq2"])
+def test_seqdata_get_seq_view(simple_dict, alpha, seqid):
+    sd = SeqData(simple_dict, alphabet=alpha)
+    seq = simple_dict[seqid]
+    seq_len = len(seq)
+    got = sd.get_seq_view(seqid)
+    assert got.seq == sd
+    assert got.stop == seq_len
+    assert got.seqid == seqid
+    assert got.seq_len == seq_len
+
+
+@pytest.mark.parametrize("seq", ("seq1", "seq2"))
+@pytest.mark.parametrize("start", (None, -1, 0, 1, 4))
+@pytest.mark.parametrize("stop", (None, -1, 0, 1, 4))
+def test_get_seq_str(simple_dict, alpha, seq, start, stop):
+    # slicing should be tested in test_get_seq_array
+    expect = simple_dict[seq][start:stop]
+    sd = SeqData(data=simple_dict, alphabet=alpha)
+    got = sd.get_seq_str(seqid=seq, start=start, stop=stop)
+    assert expect == got
+
+
+def test_get_seq_str_empty(sd_demo: SeqData):
+    with pytest.raises(TypeError):
+        sd_demo.get_seq_str()
+
+
+def test_names_get(simple_dict, alpha):
+    expect = simple_dict.keys()
+    sd = SeqData(simple_dict, alphabet=alpha)
+    got = sd.names
+    # returns iterator
+    assert list(got) == list(expect)
+
+
+@pytest.mark.parametrize(
+    "seq, moltype_name",
+    [("TCAG-NRYWSKMBDHV?", "dna"), ("UCAG-NRYWSKMBDHV?", "rna")],
+)
+def test_seq_index_str(seq, moltype_name, int_arr):
+    alpha = get_moltype(moltype_name).alphabets.degen_gapped
+    got = seq_index(seq, alpha)
+    assert numpy.array_equal(got, int_arr)
+
+
+@pytest.mark.parametrize(
+    "seq, moltype_name",
+    [(b"TCAG-NRYWSKMBDHV?", "dna"), (b"UCAG-NRYWSKMBDHV?", "rna")],
+)
+def test_seq_index_bytes(seq, moltype_name, int_arr):
+    alpha = get_moltype(moltype_name).alphabets.degen_gapped
+    got = seq_index(seq, alpha)
+    assert numpy.array_equal(got, int_arr)
+
+
+@pytest.mark.parametrize("moltype_name", ("dna", "rna"))
+def test_seq_index_arr(moltype_name, int_arr):
+    alpha = get_moltype(moltype_name).alphabets.degen_gapped
+    got = seq_index(int_arr, alpha)
+    assert numpy.array_equal(got, int_arr)
+    assert got.dtype == int_arr.dtype
+
+
+def test_get_seq_array(simple_dict, alpha):
+    # TODO: slicing should be tested here, not get_seq_str
+    # seq1
+    expect = numpy.array([2, 1, 3, 0], dtype="uint8")
+    sd = SeqData(data=simple_dict, alphabet=alpha)
+    got = sd.get_seq_array(seqid="seq1")
+    assert numpy.array_equal(got, expect)
+
+    # seq2
+    expect = numpy.array([3, 0, 0, 0, 3, 1, 2], dtype="uint8")
+    sd = SeqData(data=simple_dict, alphabet=alpha)
+    got = sd.get_seq_array(seqid="seq2")
+    assert numpy.array_equal(got, expect)
+
+
+def test_get_seq_bytes(sd_demo: SeqData):
+    # getting seqid and slicing tested in test_get_seq_str
+    got = sd_demo.get_seq_bytes(seqid="seq1")
+    assert isinstance(got, bytes)
+
+
+# SeqData set_seq_maker tests
+def test_seqdataview_make_seq_default(sd_demo):
+    assert sd_demo.make_seq is None
+
+
+@pytest.mark.parametrize(
+    "maker", (get_moltype("dna").make_seq, get_moltype("dna").make_array_seq)
+)
+def test_seqdataview_make_seq_setget(sd_demo, maker):
+    alpha = sd_demo._alphabet
+    sd_demo.make_seq = maker
+    assert sd_demo.make_seq == maker
+
+
+# SeqData __getitem__ tests
+@pytest.mark.parametrize("seq", ("seq1", "seq2"))
+def test_getitem_str(sd_demo, seq):
+    got = sd_demo[seq]
+    assert got.seq == sd_demo
+    assert got.seqid == seq
+
+
+@pytest.mark.xfail(reason="'SeqDataView' object has no attribute 'replace'")
+@pytest.mark.parametrize("idx", (0, 1))
+@pytest.mark.parametrize("make_seq", (True, False))
+def test_getitem_int(simple_dict, alpha, idx, make_seq):
+    sd = SeqData(simple_dict, alphabet=alpha)
+    ms = get_moltype("dna").make_seq if make_seq else None
+    sd.make_seq = ms
+    got = sd[idx]
+    assert got.seq == sd
+    assert got.seqid == list(simple_dict)[idx]
+
+
+@pytest.mark.xfail(reason="'SeqDataView' object has no attribute 'replace'")
+@pytest.mark.parametrize("seqid", ("seq1", "seq2"))
+@pytest.mark.parametrize("make_seq", (True, False))
+def test_getitem_str(sd_demo, seqid, make_seq):
+    ms = get_moltype("dna").make_seq if make_seq else None
+    sd_demo.make_seq = ms
+    got = sd_demo[seqid]
+    assert got.seq == sd_demo
+    assert got.seqid == seqid
+
+
+# SeqDataView tests for returning an instance of itself
+def test_seqdataview_returns_self(sd_demo: SeqData):
+    sdv = sd_demo.get_seq_view("seq1")
+    assert isinstance(sdv, SeqDataView)
+
+
+@pytest.mark.parametrize(
+    "index",
+    [
+        slice(1, 3, None),
+        slice(None, 2, None),
+        slice(3, None, None),
+        slice(0, 10, None),
+        slice(None, None, 2),
+        slice(None),
+    ],
+)
+def test_seqdataview_slice_returns_self(seq1: str, index: slice):
+    sdv = SeqDataView(seq=seq1, seqid="seq1", seq_len=len(seq1))
+    got = sdv[index]
+    assert isinstance(got, SeqDataView)
+
+
+# SeqDataView tests for value properties
+@pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
+def test_seqdataview_value(simple_dict: dict, alpha, start, stop, step):
+    seq = "seq2"
+    expect = simple_dict[seq][start:stop:step]
+    sd = SeqData(data=simple_dict, alphabet=alpha)
+    # Get SeqDataView on seq
+    sdv = sd.get_seq_view(seqid=seq)
+    sdv2 = sdv[start:stop:step]
+    got = sdv2.str_value
+    assert got == expect
+
+
+@pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
+def test_seqdata_array_value(simple_dict_arr: dict, alpha, start, stop, step):
+    seq = "seq2"
+    expect = simple_dict_arr[seq][start:stop:step]
+    sd = SeqData(data=simple_dict_arr, alphabet=alpha)
+    # Get SeqDataView on seq
+    sdv = sd.get_seq_view(seqid=seq)
+    got = sdv.array_value[start:stop:step]
+    assert numpy.array_equal(got, expect)
+
+
+@pytest.mark.parametrize("start", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("stop", (None, 0, 1, 4, -1, -4))
+@pytest.mark.parametrize("step", (None, 1, 2, 3, -1, -2, -3))
+def test_seqdata_bytes_value(simple_dict: dict, alpha, start, stop, step):
+    seq = "seq2"
+    expect = simple_dict[seq][start:stop:step]
+    expect = expect.encode("utf8")
+    sd = SeqData(data=simple_dict, alphabet=alpha)
+    # Get SeqDataView on seq
+    sdv = sd.get_seq_view(seqid=seq)
+    got = sdv.bytes_value[start:stop:step]
+    assert expect == got
+
+
+# SeqDataView tests for special methods that access "value" properties
+def test_array(sdv_s2: SeqDataView):
+    expect = sdv_s2.array_value
+    got = numpy.array(sdv_s2)
+    assert numpy.array_equal(expect, got)
+
+
+def test_bytes(sdv_s2: SeqDataView):
+    expect = sdv_s2.bytes_value
+    got = bytes(sdv_s2)
+    assert expect == got
+
+
+# AlignedSeqData tests
+def test_from_string_unequal_seqlens():
+    data = dict(seq1="A-A", seq2="AAAAAAA--")
+    with pytest.raises(ValueError):
+        AlignedData.from_gapped_seqs(data=data)
+
+
+def test_aligned_from_string_returns_self(aligned_dict):
+    got = AlignedData.from_gapped_seqs(data=aligned_dict)
+    assert isinstance(got, AlignedData)
+    # assert gap lengths
+
+
+@pytest.mark.parametrize("seqid", ("seq1", "seq2"))
+def test_get_aligned_view(aligned_dict, seqid):
+    ad = AlignedData.from_gapped_seqs(aligned_dict)
+    got = ad.get_aligned_view(seqid)
+    assert isinstance(got, AlignedDataView)
+    assert got.seq == ad
+    assert got.stop == ad.align_len
+    assert got.seq_len == ad.align_len
+
+
+# AlignedData seq to gaps
+def test_seq_to_gap_coords_str_all_gaps():
+    parent_seq = "-----"
+    expect_gaplen = numpy.array([len(parent_seq)])
+    got_ungap, got_map = seq_to_gap_coords(parent_seq, moltype=get_moltype("dna"))
+    assert got_ungap == ""
+    assert got_map.cum_gap_lengths == expect_gaplen
+
+
+def test_seq_to_gap_coords_str_no_gaps():
+    parent_seq = "ACTGC"
+    got_ungap, got_empty_arr = seq_to_gap_coords(parent_seq, moltype=get_moltype("dna"))
+    assert got_ungap == parent_seq
+    assert got_empty_arr.size == 0
+
+
+def test_seq_to_gap_coords_arr_all_gaps():
+    alpha = get_moltype("dna").alphabets.degen_gapped
+    parent_seq = seq_index("-----", alpha)
+    got_ungap, got_map = seq_to_gap_coords(parent_seq, moltype=get_moltype("dna"))
+    assert got_ungap.size == 0
+    assert got_map.get_gap_coordinates() == [[0, 5]]
+
+
+def test_seq_to_gap_coords_arr_no_gaps():
+    alpha = get_moltype("dna").alphabets.degen_gapped
+    parent_seq = seq_index("ACTGC", alpha)
+    got_ungap, got_empty_arr = seq_to_gap_coords(parent_seq, moltype=get_moltype("dna"))
+    assert numpy.array_equal(got_ungap, parent_seq)
+    assert got_empty_arr.size == 0
+
+
+@pytest.fixture
+def gap_seqs():
+    return [
+        ("A---CTG-C", [[1, 3], [4, 1]]),
+        ("-GTAC--", [[0, 1], [4, 2]]),
+        ("---AGC--TGC--", [[0, 3], [3, 2], [6, 2]]),
+    ]
+
+
+@pytest.mark.parametrize("i", range(3))  # range(len(gap_seqs()))
+def test_seq_to_gap_coords_str(gap_seqs, i):
+    seq, gap_coords = gap_seqs[i]
+    got_ungapped, got_map = seq_to_gap_coords(seq, moltype=get_moltype("dna"))
+    assert got_ungapped == seq.replace("-", "")
+    assert got_map.get_gap_coordinates() == gap_coords
+
+
+@pytest.mark.parametrize("i", range(3))  # range(len(gap_seqs()))
+def test_seq_to_gap_coords_arr(gap_seqs, i):
+    seq, gap_coords = gap_seqs[i]
+    alpha = get_moltype("dna").alphabets.degen_gapped
+    seq = seq_index(seq, alpha)  # convert to array repr
+    got_ungapped, got_map = seq_to_gap_coords(seq, moltype=get_moltype("dna"))
+    assert numpy.array_equal(got_ungapped, seq[seq != 4])  # gap_char = 4
+    assert got_map.get_gap_coordinates() == gap_coords