Repository: cogent3/cogent3
Pull request #2084 (2024-10-31 21:10:03)

REL: 2024.7.19a9


Patch:
diff --git a/.hgignore b/.hgignore
index fed9764e5..ff32c6866 100644
--- a/.hgignore
+++ b/.hgignore
@@ -38,4 +38,5 @@ working/*
 lcov*.info
 .ruff_cache/*
 .devcontainer/*
-hg_old.zip
\ No newline at end of file
+hg_old*zip
+hg_old/*
\ No newline at end of file
diff --git a/README.md b/README.md
index 3a99654fb..85e26356c 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,8 @@
+
+<p align="left">
+  <img src="https://raw.githubusercontent.com/cogent3/cogent3.github.io/e72df8c155c100f502b6a7009347d1821ab3adef/doc/_static/c3-logo.svg" width="300">
+</p>
+
 [![PyPI version](https://badge.fury.io/py/cogent3.svg)](https://badge.fury.io/py/cogent3)
 [![Downloads](https://pepy.tech/badge/cogent3/month)](https://pepy.tech/project/cogent3)
 
@@ -10,11 +15,6 @@
 [![CodeQL](https://github.com/cogent3/cogent3/actions/workflows/codeql.yml/badge.svg)](https://github.com/cogent3/cogent3/actions/workflows/codeql.yml)
 [![Codacy Badge](https://app.codacy.com/project/badge/Grade/e80e3441de59449bb1a4d8ad1fdea4fa)](https://app.codacy.com/gh/cogent3/cogent3/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
 
-<p align="left">
-  <img src="https://raw.githubusercontent.com/cogent3/cogent3.github.io/e72df8c155c100f502b6a7009347d1821ab3adef/doc/_static/c3-logo.svg" width="300">
-</p>
-
-
 `cogent3` is a mature python library for analysis of genomic sequence data. We endeavour to provide a first-class experience within Jupyter notebooks, but the algorithms also support parallel execution on compute systems with 1000's of processors.
 
 ## 📣 Feature Announcements 📣
diff --git a/changelog.md b/changelog.md
index 4bdb41039..6994f36fa 100755
--- a/changelog.md
+++ b/changelog.md
@@ -1,4 +1,26 @@
 
+<a id='changelog-2024.7.19a9'></a>
+# Changes in release "2024.7.19a9"
+
+## ENH
+
+- `cogent3.open_()` now automatically handles `.xz` and `.lzma` compressed files.
+- Formatting of fasta records is now much quicker.
+- Standardise arrow head sizes on annotation feature displays.
+
+<a id='changelog-2024.7.19a8'></a>
+# Changes in release "2024.7.19a8"
+
+A single bugfix release.
+
+## Contributors
+
+- Bug identified and fixed by @YapengLang 🚀🤩
+
+## BUG
+
+- Fixed a regression in Tree's arising from new name / support split functionality.
+
 <a id='changelog-2024.7.19a7'></a>
 # Changes in release "2024.7.19a7"
 
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index e33d45b35..4acd6580c 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -11,13 +11,13 @@ from typing import Callable, Optional, Union
 
 from cogent3._version import __version__
 from cogent3.app import app_help, available_apps, get_app, open_data_store
+from cogent3.core import annotation_db as _anno_db
 from cogent3.core.alignment import (
     Alignment,
     ArrayAlignment,
     Sequence,
     SequenceCollection,
 )
-from cogent3.core.annotation_db import load_annotations
 from cogent3.core.genetic_code import available_codes, get_code
 
 # note that moltype has to be imported last, because it sets the moltype in
@@ -31,14 +31,11 @@ from cogent3.core.moltype import (
     get_moltype,
 )
 from cogent3.core.tree import PhyloNode, TreeBuilder, TreeError, TreeNode
-from cogent3.evolve.fast_distance import (
-    available_distances,
-    get_distance_calculator,
-)
+from cogent3.evolve.fast_distance import available_distances, get_distance_calculator
 from cogent3.evolve.models import available_models, get_model
 from cogent3.parse.cogent3_json import load_from_json
 from cogent3.parse.newick import parse_string as newick_parse_string
-from cogent3.parse.sequence import get_parser
+from cogent3.parse.sequence import get_parser, is_genbank
 from cogent3.parse.table import load_delimited
 from cogent3.parse.tree_xml import parse_string as tree_xml_parse_string
 from cogent3.util.io import get_format_suffixes, open_
@@ -75,6 +72,8 @@ import logging
 __numba_logger = logging.getLogger("numba")
 __numba_logger.setLevel(logging.WARNING)
 
+load_annotations = _anno_db.load_annotations
+
 
 def make_seq(
     seq,
@@ -82,6 +81,7 @@ def make_seq(
     moltype=None,
     new_type: bool = False,
     annotation_offset: int = 0,
+    annotation_db: Optional[_anno_db.SupportsFeatures] = None,
     **kw: dict,
 ):  # refactor: type hinting, need to capture optional args and the return type
     """
@@ -116,6 +116,8 @@ def make_seq(
     seq = moltype.make_seq(
         seq=seq, name=name, annotation_offset=annotation_offset, **kw
     )
+    if annotation_db:
+        seq.annotation_db = annotation_db
     return seq
 
 
@@ -278,6 +280,30 @@ def _load_seqs(file_format, filename, fmt, parser_kw):
     return list(parser(filename, **parser_kw))
 
 
+T = Optional[_anno_db.SupportsFeatures]
+
+
+def _load_genbank_seq(
+    filename: os.PathLike, parser_kw: dict, just_seq: bool = False
+) -> tuple[str, str, T]:
+    """utility function for loading sequences"""
+    from cogent3.parse.genbank import iter_genbank_records
+
+    for name, seq, features in iter_genbank_records(filename, **parser_kw):
+        break
+    else:
+        raise ValueError(f"No sequences found in {filename}")
+
+    db = (
+        None
+        if just_seq
+        else _anno_db.GenbankAnnotationDb(
+            data=features.pop("features", None), seqid=name
+        )
+    )
+    return name, seq, db
+
+
 def load_seq(
     filename: os.PathLike,
     annotation_path: Optional[os.PathLike] = None,
@@ -295,17 +321,19 @@ def load_seq(
 
     Parameters
     ----------
-    filename : str
+    filename
         path to sequence file
-    format : str
+    annotation_path
+        path to annotation file, ignored if format is genbank
+    format
         sequence file format, if not specified tries to guess from the path suffix
-    moltype : str
+    moltype
         the moltype, eg DNA, PROTEIN, 'dna', 'protein'
-    label_to_name : callable
+    label_to_name
         function for converting original name into another name.
-    parser_kw : dict
+    parser_kw
         optional arguments for the parser
-    info : dict
+    info
         a dict from which to make an info object
     new_type
         if True, returns a new type Sequence (cogent3.core.new_sequence.Sequence)
@@ -329,12 +357,22 @@ def load_seq(
     info["source"] = str(filename)
     file_format, _ = get_format_suffixes(filename)
     if file_format == "json":
-        seq = load_from_json(filename, (Sequence,))
+        seq = load_from_json(filename, (Sequence,))  # need to support new seq here
         seq.name = label_to_name(seq.name) if label_to_name else seq.name
         return seq
 
-    data = _load_seqs(file_format, filename, format, parser_kw)
-    name, seq = data[0]
+    if is_genbank(format or file_format):
+        name, seq, db = _load_genbank_seq(
+            filename, parser_kw or {}, just_seq=annotation_path is not None
+        )
+    else:
+        db = None
+        data = _load_seqs(file_format, filename, format, parser_kw)
+        name, seq = data[0]
+
+    if annotation_path is not None:
+        db = load_annotations(path=annotation_path, seqids=[name])
+
     name = label_to_name(name) if label_to_name else name
 
     result = make_seq(
@@ -343,15 +381,11 @@ def load_seq(
         moltype=moltype,
         new_type=new_type,
         annotation_offset=annotation_offset,
+        annotation_db=db,
         **kw,
     )
     result.info.update(info)
 
-    if getattr(seq, "annotation_db", None):
-        result.annotation_db = seq.annotation_db
-
-    if annotation_path is not None:
-        result.annotation_db = load_annotations(path=annotation_path, seqids=[name])
     return result
 
 
@@ -686,7 +720,13 @@ def load_table(
     )
 
 
-def make_tree(treestring=None, tip_names=None, format=None, underscore_unmunge=False):
+def make_tree(
+    treestring=None,
+    tip_names=None,
+    format=None,
+    underscore_unmunge=False,
+    name_nodes=False,
+):
     """Initialises a tree.
 
     Parameters
@@ -701,6 +741,8 @@ def make_tree(treestring=None, tip_names=None, format=None, underscore_unmunge=F
     underscore_unmunge : bool
         replace underscores with spaces in all names read, i.e. "sp_name"
         becomes "sp name"
+    name_nodes: bool
+        whether to name unnamed nodes
 
     Notes
     -----
@@ -714,7 +756,7 @@ def make_tree(treestring=None, tip_names=None, format=None, underscore_unmunge=F
     assert treestring or tip_names, "must provide either treestring or tip_names"
     if tip_names:
         tree_builder = TreeBuilder().create_edge
-        tips = [tree_builder([], tip_name, {}) for tip_name in tip_names]
+        tips = [tree_builder([], str(tip_name), {}) for tip_name in tip_names]
         tree = tree_builder(tips, "root", {})
         return tree
 
@@ -730,6 +772,10 @@ def make_tree(treestring=None, tip_names=None, format=None, underscore_unmunge=F
     if not tree.name_loaded:
         tree.name = "root"
 
+    # ensure all nodes have names if name_nodes is True
+    if name_nodes:
+        tree.name_unnamed_nodes()
+
     return tree
 
 
diff --git a/src/cogent3/_version.py b/src/cogent3/_version.py
index c4a1e2a1c..3d3862aef 100644
--- a/src/cogent3/_version.py
+++ b/src/cogent3/_version.py
@@ -1,1 +1,1 @@
-__version__ = "2024.7.19a7"
+__version__ = "2024.7.19a9"
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index 8ed065ed6..ab068dc6a 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -637,11 +637,6 @@ class _SequenceCollectionBase:
             return {}  # safe value; can't construct empty alignment
 
         result = self.__class__(result, names=seqs, info=self.info, **kwargs)
-        if self.annotation_db:
-            result.annotation_db = type(self.annotation_db)()
-            result.annotation_db.update(
-                annot_db=self.annotation_db, seqids=result.names
-            )
         return result
 
     def get_seq_indices(self, f, negate=False):  # ported
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index af8f5aa4c..254546970 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -274,7 +274,7 @@ class Feature:
         """returns plotly trace"""
         from cogent3.draw.drawable import make_shape
 
-        return make_shape(type_=self)
+        return make_shape(type_=self, parent_length=len(self.parent))
 
     def to_dict(self):
         """returns"""
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index a2984e56f..d2a66bf10 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -141,6 +141,10 @@ class AlphabetABC(ABC):
     @abstractmethod
     def from_rich_dict(cls, data: dict) -> None: ...
 
+    @property
+    def moltype(self) -> typing.Union["MolType", None]:
+        return _alphabet_moltype_map.get(self, None)
+
 
 class MonomerAlphabetABC(ABC):
     @abstractmethod
@@ -382,7 +386,10 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
         words += (gap,) if include_gap and gap else ()
         words += (missing,) if include_gap and missing else ()
 
-        return KmerAlphabet(words=words, monomers=self, gap=gap, k=k, missing=missing)
+        kalpha = KmerAlphabet(words=words, monomers=self, gap=gap, k=k, missing=missing)
+        if self.moltype:
+            _alphabet_moltype_map[kalpha] = self.moltype
+        return kalpha
 
     @functools.singledispatchmethod
     def is_valid(self, seq: StrORBytesORArray) -> bool:
@@ -855,7 +862,7 @@ def deserialise_kmer_alphabet(data: dict) -> KmerAlphabet:
     return KmerAlphabet.from_rich_dict(data)
 
 
-class CodonAlphabet(tuple):
+class CodonAlphabet(tuple, AlphabetABC):
     """represents the sense-codons of a GeneticCode"""
 
     def __new__(
@@ -897,6 +904,8 @@ class CodonAlphabet(tuple):
         self._to_indices = {codon: i for i, codon in enumerate(self)}
         self._from_indices = {i: codon for codon, i in self._to_indices.items()}
         self.motif_length = 3
+        if monomers.moltype:
+            _alphabet_moltype_map[self] = monomers.moltype
 
     @property
     def gap_char(self):
@@ -1012,8 +1021,8 @@ def make_alphabet(*, chars, gap, missing, moltype):
 
     Notes
     -----
-    The moltype is associated with the alphabet, available as an
-    alphabet property.
+    The moltype is associated with the alphabet, available as the
+    alphabet.moltype property.
     """
     alpha = CharAlphabet(chars, gap=gap, missing=missing)
     _alphabet_moltype_map[alpha] = moltype
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index c0222a45c..dc78a0a7a 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -565,9 +565,8 @@ class MolType:
             additional keyword arguments that may be required for creating the
             Sequence object
         """
-        # refactor: design
-        # for Sequence and SeqView object, a consistency check with moltype/alphabet
-        # should be performed
+        if hasattr(seq, "moltype"):
+            return seq if seq.moltype is self else seq.to_moltype(self)
 
         if check_seq:
             assert self.is_valid(
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index 8f3c29474..5cde13ae2 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -1177,8 +1177,8 @@ class Sequence:
 
         if not moltype.most_degen_alphabet().is_valid(seq):
             raise ValueError(
-                f"Changing from old moltype={self.moltype.label} to new "
-                f"moltype={moltype.label} is not valid for this data"
+                f"Changing from old moltype={self.moltype.label!r} to new "
+                f"moltype={moltype.label!r} is not valid for this data"
             )
         sv = SeqView(seq=seq, alphabet=moltype.most_degen_alphabet())
         new = self.__class__(moltype=moltype, seq=sv, name=self.name, info=self.info)
@@ -2648,6 +2648,17 @@ def _coerce_to_seqview(data, seqid, alphabet, offset) -> SeqViewABC:
 
 @_coerce_to_seqview.register
 def _(data: SeqViewABC, seqid, alphabet, offset) -> SeqViewABC:
+    # we require the indexes of shared states in alphabets to be the same
+    # SeqView has an alphabet but SeqViewABC does NOT because that is
+    # more general and covers the case where the SeqsData collection has the
+    # alphabet
+    if hasattr(data, "alphabet"):
+        n = min(len(data.alphabet), len(alphabet))
+        if data.alphabet[:n] != alphabet[:n]:
+            raise new_alphabet.AlphabetError(
+                f"element order {data.alphabet=} != to that in {alphabet=} for {data=!r}"
+            )
+
     if offset and data.offset:
         raise ValueError(
             f"cannot set {offset=} on a SeqView with an offset {data.offset=}"
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 3bdbf6c34..c3c88fb90 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -128,10 +128,16 @@ class TreeNode:
     # return self.name != other.name
 
     def __lt__(self, other):
-        return self.name < other.name
+        self_name = self.name or ""
+        other_name = other.name or ""
+
+        return self_name < other_name
 
     def __gt__(self, other):
-        return self.name > other.name
+        self_name = self.name or ""
+        other_name = other.name or ""
+
+        return self_name > other_name
 
     def compare_name(self, other):
         """Compares TreeNode by name"""
diff --git a/src/cogent3/draw/drawable.py b/src/cogent3/draw/drawable.py
index a2fbbd70a..6d9c3082e 100644
--- a/src/cogent3/draw/drawable.py
+++ b/src/cogent3/draw/drawable.py
@@ -1,5 +1,6 @@
 import os
 import pathlib
+import typing
 
 import numpy
 
@@ -554,6 +555,7 @@ class Shape:
         showlegend=True,
         hoverinfo=None,
         fillcolor=None,
+        **kwargs,
     ):
         self.filled = filled
         self.fillcolor = fillcolor
@@ -672,58 +674,126 @@ class Diamond(Shape):
         self.y = numpy.array(ys)
 
 
+def _calc_arrow_width(
+    *,
+    parent_length: int,
+    feature_width: float,
+    frac: float = 0.05,
+) -> float:
+    aw = parent_length * frac
+    if aw > feature_width:
+        aw = feature_width * frac
+    return aw
+
+
+def _make_rectangles(coords: list[list[int]], y=0, height=0.25) -> tuple[list[float]]:
+    xs = []
+    ys = []
+    y_coord = [y, y + height, y + height, y, y]
+    for coord in coords:
+        # Add coordinates for individual rectangle
+        width = abs(coord[0] - coord[1])
+        x_coord = min(coord[0], coord[1])
+        xs.append([x_coord, x_coord, x_coord + width, x_coord + width, x_coord])
+        ys.append(y_coord)
+    return xs, ys
+
+
+def _make_arrow_head(
+    coord: list[float],
+    y: float,
+    height: float,
+    head_width_frac: float,
+    reverse: bool,
+    parent_length: int,
+) -> tuple[list[float]]:
+    width = abs(coord[0] - coord[1])
+    x_coord = min(coord[0], coord[1])
+    hh = height * head_width_frac * 2
+    hw = _calc_arrow_width(
+        parent_length=parent_length, feature_width=width, frac=head_width_frac
+    )
+
+    # Coordinates for arrow head
+    arrow_x = [
+        x_coord,
+        x_coord + width - hw,
+        x_coord + width - hw,
+        x_coord + width,
+        x_coord + width - hw,
+        x_coord + width - hw,
+        x_coord,
+        x_coord,
+    ]
+    arrow_y = [
+        y,
+        y,
+        y - hh,
+        y + height / 2,
+        y + height + hh,
+        y + height,
+        y + height,
+        y,
+    ]
+    if reverse:
+        arrow_x = numpy.array(arrow_x)
+        arrow_x = list(numpy.flip(arrow_x.max() - arrow_x + arrow_x.min()))
+        arrow_y = list(numpy.flip(arrow_y))
+    return arrow_x, arrow_y
+
+
+def _connecting_lines(
+    x_grp: list[list[float]], y_grp: list[list[float]], connect_y: float
+) -> tuple[list[typing.Union[float, None]]]:
+    new_xs, new_ys = x_grp[0][:], y_grp[0][:]
+    y_connect_coord = [None, connect_y, connect_y, None]
+    for i, (xs, ys) in enumerate(zip(x_grp[1:], y_grp[1:]), 1):
+        new_xs.extend([None, max(x_grp[i - 1]), min(x_grp[i]), None] + xs)
+        new_ys.extend(y_connect_coord + ys)
+    return new_xs, new_ys
+
+
 class Arrow(Shape):
     def __init__(
-        self, coords, y=0, height=0.25, arrow_head_w=0.1, reverse=False, **kwargs
+        self,
+        coords,
+        y=0,
+        height=0.25,
+        arrow_head_w=0.05,
+        reverse=False,
+        parent_length: typing.Optional[int] = None,
+        **kwargs,
     ):
-        super(Arrow, self).__init__(**kwargs)
-        xs = []
-        ys = []
-        for i in range(len(coords) - 1):
-            # Add coordinates for individual rectangle
-            width = abs(coords[i][0] - coords[i][1])
-            x_coord = min(coords[i][0], coords[i][1])
-            xs += [x_coord, x_coord, x_coord + width, x_coord + width, x_coord]
-            ys += [y, y + height, y + height, y, y]
-            # Add coordinates for connecting line segment
-            xs += [None, coords[i][1], coords[i + 1][0], None]
-            ys += [None, y + height / 2, y + height / 2, None]
+        super().__init__(**kwargs)
 
-        width = abs(coords[-1][0] - coords[-1][1])
-        x_coord = min(coords[-1][0], coords[-1][1])
-        hh = height * arrow_head_w * 2
-        hw = width * arrow_head_w * 2
+        if len(coords) == 1 or reverse:
+            head_index = 0
+            rec_coords = None if len(coords) == 1 else coords[1:]
+        else:
+            head_index = -1
+            rec_coords = coords[:-1]
 
-        # Coordinates for arrow head
-        arrow_x = [
-            x_coord,
-            x_coord + width - hw,
-            x_coord + width - hw,
-            x_coord + width,
-            x_coord + width - hw,
-            x_coord + width - hw,
-            x_coord,
-            x_coord,
-        ]
-        arrow_y = [
-            y,
-            y,
-            y - hh,
-            y + height / 2,
-            y + height + hh,
-            y + height,
-            y + height,
-            y,
-        ]
-        if not reverse:
-            xs += arrow_x
-            ys += arrow_y
+        arrow_x, arrow_y = _make_arrow_head(
+            coords[head_index],
+            y=y,
+            height=height,
+            head_width_frac=arrow_head_w,
+            reverse=reverse,
+            parent_length=parent_length,
+        )
+        if rec_coords:
+            xs, ys = _make_rectangles(rec_coords, y=y, height=height)
+        else:
+            xs, ys = [], []
+
+        if reverse:
+            xs.insert(0, arrow_x)
+            ys.insert(0, arrow_y)
         else:
-            arrow_x = numpy.array(arrow_x)
-            arrow_y = numpy.array(arrow_y)
-            xs += list(numpy.flip(arrow_x.max() - arrow_x + arrow_x.min()))
-            ys += list(numpy.flip(arrow_y))
+            xs.append(arrow_x)
+            ys.append(arrow_y)
 
+        xs, ys = _connecting_lines(xs, ys, y + height / 2)
         self.x = numpy.array(xs)
         self.y = numpy.array(ys)
 
@@ -747,6 +817,7 @@ class _MakeShape:
     _colors = dict(
         cds="rgba(0,0,150,0.75)",
         rrna="rgba(0,0,150,0.75)",
+        misc_rna="rgba(0,0,150,0.75)",
         trna="rgba(0,0,150,0.75)",
         exon="rgba(0,0,100,0.75)",
         gene="rgba(161,0,0,0.75)",
@@ -758,6 +829,7 @@ class _MakeShape:
     _shapes = dict(
         cds=Arrow,
         rrna=Arrow,
+        misc_rna=Arrow,
         trna=Arrow,
         exon=Arrow,
         transcript=Arrow,
@@ -785,7 +857,7 @@ class _MakeShape:
         kwargs |= dict(reverse=reverse)
 
         klass = self._shapes.get(type_.lower(), Rectangle)
-        color = self._colors.get(type_.lower())
+        color = self.get_colour(type_)
         if klass != Arrow:
             kwargs.pop("reverse", None)
 
@@ -812,5 +884,15 @@ class _MakeShape:
             )
         return result
 
+    def get_colour(self, label):
+        from plotly.colors import sample_colorscale
+
+        label = label.lower()
+
+        if label not in self._colors:
+            self._colors[label] = sample_colorscale("Viridis", numpy.random.rand())[0]
+
+        return self._colors[label]
+
 
 make_shape = _MakeShape()
diff --git a/src/cogent3/format/fasta.py b/src/cogent3/format/fasta.py
index 29fe3433e..eba3ccd54 100644
--- a/src/cogent3/format/fasta.py
+++ b/src/cogent3/format/fasta.py
@@ -1,12 +1,17 @@
 """Writer for FASTA sequence format"""
 
-import textwrap
-from typing import Optional
+from typing import Iterable, Optional
 
 from cogent3.format.util import _AlignmentFormatter
 from cogent3.util import warning as c3warn
 
 
+def _iter_in_block_size(series: str, block_size: int) -> Iterable[str]:
+    """Yield chunks of a series of items"""
+    for i in range(0, len(series), block_size):
+        yield series[i : i + block_size]
+
+
 @c3warn.deprecated_args(
     "2024.9", reason="better name", old_new=[("alignment_dict", "seqs")]
 )
@@ -37,7 +42,11 @@ def seqs_to_fasta(
     result = []
     for name in order:
         result.append(f">{name}")
-        result.extend(textwrap.wrap(str(seqs[name]), block_size))
+        seq = str(seqs[name])
+        if len(seq) <= block_size:
+            result.append(seq)
+        else:
+            result.extend(_iter_in_block_size(seq, block_size))
     if result:
         result.append("")
     return "\n".join(result)
diff --git a/src/cogent3/parse/fasta.py b/src/cogent3/parse/fasta.py
index f268ce72a..7fb4d877a 100644
--- a/src/cogent3/parse/fasta.py
+++ b/src/cogent3/parse/fasta.py
@@ -16,7 +16,7 @@ from cogent3.core.info import Info
 from cogent3.core.moltype import ASCII, BYTES
 from cogent3.parse.record import RecordError
 from cogent3.parse.record_finder import LabeledRecordFinder
-from cogent3.util.io import open_
+from cogent3.util.io import is_url, open_
 
 _white_space = re.compile(r"\s+")
 strip = str.strip
@@ -442,6 +442,14 @@ def _(
 
 @iter_fasta_records.register
 def _(data: str, converter: OptConverterType = None, label_to_name: RenamerType = str):
+    if not is_url(data):
+        try:
+            os.stat(data)
+        except OSError:
+            raise TypeError(
+                "data is a string but not a file path, directly provided data must be bytes"
+            )
+
     with open_(data, mode="rb") as infile:
         data: bytes = infile.read()
     return iter_fasta_records(data, converter=converter, label_to_name=label_to_name)
diff --git a/src/cogent3/parse/sequence.py b/src/cogent3/parse/sequence.py
index dbdfbc1c7..b77541a0e 100644
--- a/src/cogent3/parse/sequence.py
+++ b/src/cogent3/parse/sequence.py
@@ -142,3 +142,11 @@ def get_parser(fmt: str) -> typing.Callable[[SeqParserInputTypes], ParserOutputT
         return PARSERS[fmt]
     except KeyError:
         raise ValueError(f"Unsupported format {fmt!r}")
+
+
+def is_genbank(fmt: str) -> bool:
+    """whether the provided format is a genbank format"""
+    try:
+        return get_parser(fmt).__module__.endswith("genbank")
+    except ValueError:
+        return False
diff --git a/src/cogent3/util/io.py b/src/cogent3/util/io.py
index 1c9891a6a..901b4c364 100644
--- a/src/cogent3/util/io.py
+++ b/src/cogent3/util/io.py
@@ -1,12 +1,13 @@
 import contextlib
+import functools
 import shutil
 import uuid
 from bz2 import open as bzip_open
 from gzip import open as gzip_open
 from io import TextIOWrapper
+from lzma import open as lzma_open
 from os import PathLike, remove
 from pathlib import Path, PurePath
-from re import compile
 from tempfile import mkdtemp
 from typing import IO, Callable, Iterator, Optional, Tuple, Union
 from urllib.parse import ParseResult, urlparse
@@ -18,8 +19,27 @@ from chardet import detect
 from cogent3.util.misc import _wout_period
 
 PathType = Union[str, PathLike, PurePath]
-# support prefixes for urls
-_urls = compile("^(http[s]*|file)")
+
+
+@functools.singledispatch
+def is_url(path: Union[str, bytes, Path]) -> bool:
+    """whether a path is a url"""
+    return False
+
+
+@is_url.register
+def _(path: str) -> bool:
+    return is_url(urlparse(path))
+
+
+@is_url.register
+def _(path: bytes) -> bool:
+    return is_url(urlparse(path.decode("utf8")))
+
+
+@is_url.register
+def _(path: ParseResult) -> bool:
+    return path.scheme in {"http", "https", "file"}
 
 
 def _get_compression_open(
@@ -41,7 +61,7 @@ def _get_compression_open(
     assert path or compression
     if compression is None:
         _, compression = get_format_suffixes(path)
-    return {"gz": gzip_open, "bz2": bzip_open, "zip": open_zip}.get(compression, None)
+    return _compression_handlers.get(compression, None)
 
 
 def open_zip(filename: PathType, mode: str = "r", **kwargs) -> IO:
@@ -72,10 +92,16 @@ def open_zip(filename: PathType, mode: str = "r", **kwargs) -> IO:
 
         opened = zf.open(zf.namelist()[0], mode=mode, **kwargs)
 
-        if binary_mode:
-            return opened
+        return opened if binary_mode else TextIOWrapper(opened, encoding=encoding)
+
 
-        return TextIOWrapper(opened, encoding=encoding)
+_compression_handlers = {
+    "gz": gzip_open,
+    "bz2": bzip_open,
+    "zip": open_zip,
+    "xz": lzma_open,
+    "lzma": lzma_open,
+}
 
 
 def open_(filename: PathType, mode="rt", **kwargs) -> IO:
@@ -97,7 +123,7 @@ def open_(filename: PathType, mode="rt", **kwargs) -> IO:
     if not filename:
         raise ValueError(f"{filename} not a valid file name or url")
 
-    if _urls.search(str(filename)):
+    if is_url(filename):
         return open_url(filename, mode=mode, **kwargs)
 
     mode = mode or "rt"
@@ -116,7 +142,7 @@ def open_(filename: PathType, mode="rt", **kwargs) -> IO:
     return op(filename, mode, encoding=encoding, **kwargs)
 
 
-def open_url(url: Union[str, ParseResult], mode="r", **kwargs) -> IO:
+def open_url(url: Union[str, ParseResult], mode="rt", **kwargs) -> IO:
     """open a url
 
     Parameters
@@ -128,33 +154,29 @@ def open_url(url: Union[str, ParseResult], mode="r", **kwargs) -> IO:
 
     Raises
     ------
-    If not http(s).
+    Rasies IOError if mode is write or it's not a url.
 
-    Notes
-    -----
-    If mode='b' or 'rb' (binary read), the function returns file object to read
-    else returns TextIOWrapper to read text with specified encoding in the URL
+    Returns
+    -------
+    file object which reads binary if "b" in mode, else text.
     """
     _, compression = get_format_suffixes(
         getattr(url, "path", url)
     )  # handling possibility of ParseResult
-    mode = "rb" if compression else mode or "r"
-
-    url_parsed = url if isinstance(url, ParseResult) else urlparse(url)
+    mode = mode or "r"
 
     if "r" not in mode:
-        raise ValueError("opening a url only allowed in read mode")
+        raise IOError("opening a url only allowed in read mode")
 
-    if not _urls.search(url_parsed.scheme):
-        raise ValueError(
-            f"URL scheme must be http, https or file, not {url_parsed.scheme}"
-        )
+    if not is_url(url):
+        raise IOError(f"URL scheme must be http, https or file, not {str(url)[:20]!r}")
+
+    url_parsed = url if isinstance(url, ParseResult) else urlparse(url)
 
     response = urlopen(url_parsed.geturl(), timeout=10)
     encoding = response.headers.get_content_charset()
     if compression:
         response = _get_compression_open(compression=compression)(response)
-        mode = "r"  # wrap it as text
 
     return response if "b" in mode else TextIOWrapper(response, encoding=encoding)
 
@@ -311,9 +333,8 @@ def get_format_suffixes(filename: PathType) -> Tuple[T, T]:
     if not filename.suffix:
         return None, None
 
-    compression_suffixes = ("bz2", "gz", "zip")
     suffixes = [_wout_period.sub("", sfx).lower() for sfx in filename.suffixes[-2:]]
-    if suffixes[-1] in compression_suffixes:
+    if suffixes[-1] in _compression_handlers:
         cmp_suffix = suffixes[-1]
     else:
         cmp_suffix = None
@@ -363,7 +384,7 @@ def iter_splitlines(
     -----
     Loads chunks of data from the file, yields one line at a time
     """
-    if _urls.search(str(path)):
+    if is_url(path):
         chunk_size = None
     else:
         path = Path(path).expanduser()


Test patch:
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index 023633c36..f144ef434 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -9,7 +9,6 @@ from cogent3 import (
     make_unaligned_seqs,
 )
 from cogent3.core.alignment import Alignment, SequenceCollection
-from cogent3.core.annotation_db import BasicAnnotationDb
 from cogent3.core.location import FeatureMap, Span
 
 DNA = get_moltype("dna")
diff --git a/tests/test_core/test_core_standalone.py b/tests/test_core/test_core_standalone.py
index a25dc62a6..139124a79 100644
--- a/tests/test_core/test_core_standalone.py
+++ b/tests/test_core/test_core_standalone.py
@@ -923,3 +923,11 @@ def test_load_seq_moltype_offset(moltype, offset, DATA_DIR):
     )
     assert seq.moltype.label == moltype
     assert seq.annotation_offset == offset
+
+
+@pytest.mark.parametrize("ann_path", [True, False])
+@pytest.mark.parametrize("new_type", [True, False])
+def test_load_seq_genank(ann_path, new_type, DATA_DIR):
+    path = DATA_DIR / "annotated_seq.gb"
+    s = load_seq(path, annotation_path=path if ann_path else None, new_type=new_type)
+    assert len(s.annotation_db) == 10
diff --git a/tests/test_core/test_new_alphabet.py b/tests/test_core/test_new_alphabet.py
index efe81e9c0..7af05f64d 100644
--- a/tests/test_core/test_new_alphabet.py
+++ b/tests/test_core/test_new_alphabet.py
@@ -604,3 +604,21 @@ def test_codon_alphabet_serlialise_round_trip(calpha):
     assert isinstance(got, new_alphabet.CodonAlphabet)
     assert list(got) == list(calpha)
     assert calpha.to_index("TTC") == 1
+
+
+@pytest.fixture(params=new_moltype.DNA.iter_alphabets())
+def alpha(request):
+    return request.param
+
+
+def test_alphabet_moltype(alpha):
+    assert alpha.moltype is new_moltype.DNA
+
+
+def test_alpha_no_moltype():
+    alpha = new_alphabet.CharAlphabet(list("ACG"))
+    assert alpha.moltype is None
+
+
+def test_codon_alphabet_moltype(calpha):
+    assert calpha.moltype is new_moltype.DNA
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index c83ba2d6a..a2bbd6217 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -572,3 +572,27 @@ def test_char_order(moltype, zero, second, last):
     assert attr[0] == zero
     assert attr[2] == second
     assert attr[-1] == last
+
+
+@pytest.mark.parametrize(
+    "seq",
+    [
+        mt.make_seq(seq="ACGG")
+        for mt in (
+            new_moltype.DNA,
+            new_moltype.RNA,
+            new_moltype.PROTEIN,
+            new_moltype.ASCII,
+        )
+    ],
+)
+def test_make_seq_with_seq(seq):
+    new_seq = new_moltype.DNA.make_seq(seq=seq)
+    assert new_seq.moltype is new_moltype.DNA
+    assert (new_seq is seq) if seq.moltype.name == "dna" else seq is not new_seq
+
+
+def test_make_seq_with_seq_invalid_moltype():
+    seq = new_moltype.PROTEIN.make_seq(seq="ACQK")
+    with pytest.raises(ValueError):
+        _ = new_moltype.DNA.make_seq(seq=seq)
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index f3a03ddb1..ca414a010 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -2595,3 +2595,33 @@ def test_load_invalid_moltype(aa_moltype):
     with pytest.raises(AssertionError):
         # this should be an AlphabetError
         cogent3.load_seq(aa_moltype, moltype="dna", new_type=True)
+
+
+@pytest.fixture(params=(new_moltype.DNA.alphabet, new_moltype.DNA.gapped_alphabet))
+def seqview(request):
+    return new_sequence.SeqView(seq="ACGT", alphabet=request.param, seqid="seq1")
+
+
+@pytest.mark.parametrize(
+    "alpha", (new_moltype.DNA.alphabet, new_moltype.DNA.degen_gapped_alphabet)
+)
+def test_make_seq_compatible_alpha(seqview, alpha):
+    got = new_sequence._coerce_to_seqview(seqview, seqview.seqid, alpha, 0)
+    assert got is seqview
+
+
+def test_make_seq_general_alpha_incompatible(seqview):
+    with pytest.raises(new_alphabet.AlphabetError):
+        alpha, seqview.alphabet = (
+            seqview.alphabet,
+            new_moltype.PROTEIN.degen_gapped_alphabet,
+        )
+        new_sequence._coerce_to_seqview(seqview, seqview.seqid, alpha, 0)
+
+
+def test_make_seq_wrong_order_alpha():
+    sv = new_sequence.SeqView(
+        seq="ACGT", alphabet=new_moltype.DNA.gapped_alphabet, seqid="seq1"
+    )
+    with pytest.raises(new_alphabet.AlphabetError):
+        new_sequence._coerce_to_seqview(sv, sv.seqid, new_moltype.DNA.degen_alphabet, 0)
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 28a2c0f63..d70e61cf6 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -49,6 +49,10 @@ class TreeTests(TestCase):
         self.assertEqual(names, ["a a", "b b", "c c"])
         self.assertEqual(str(t), result_str)
         self.assertEqual(t.get_newick(with_distances=True), result_str)
+        # ensure tip names are converted to strings
+        # when creating a tree from a list of integer tip names.
+        t = make_tree(tip_names=[1, 2, 3])
+        self.assertEqual(t.get_tip_names(), ["1", "2", "3"])
 
 
 def _new_child(old_node, constructor):
@@ -1229,6 +1233,14 @@ class TreeNodeTests(TestCase):
         result = self.t.compare_by_subsets(self.TreeRoot)
         self.assertEqual(result, 1)
 
+    def test_treenode_comparison_with_none_name(self):
+        assert self.Empty < self.Single
+        assert self.Single > self.Empty
+        assert self.Single > TreeNode(name=None)
+        assert TreeNode(name=None) < self.Single
+        assert TreeNode(name="test") > self.Empty
+        assert self.Empty < TreeNode(name="test")
+
 
 class PhyloNodeTests(TestCase):
     """Tests of phylogeny-specific methods."""
@@ -2372,3 +2384,20 @@ def test_phylonode_support():
     name_and_support = tree.get_node_matching_name("def")
     assert name_and_support.name == "def"  # bit redundant given selection process
     assert name_and_support.params["support"] == 25.0
+
+
+def test_phylonode_support_name_nodes_false():
+    # test that internal node names are None with name_nodes=False
+    tree = make_tree("((1,2)5,(3,4)6);", name_nodes=False)
+    internal_nodes = [node.name for node in tree.iter_nontips()]
+    assert all(node is None for node in internal_nodes)
+
+
+def test_phylonode_support_name_nodes_true():
+    # test that all nodes have unique names with name_nodes=True
+    tree = make_tree("((1,2)5,(3,4)6);", name_nodes=True)
+    node_names = set(tree.get_node_names())
+    # check that no node name is an empty string or None
+    assert all(node_names)
+    # check that the total number of unique node names is 7
+    assert len(node_names) == 7
diff --git a/tests/test_draw/test_draw_integration.py b/tests/test_draw/test_draw_integration.py
index b2129376d..ccacdf408 100644
--- a/tests/test_draw/test_draw_integration.py
+++ b/tests/test_draw/test_draw_integration.py
@@ -1,11 +1,20 @@
 import pathlib
 import unittest
+import uuid
 
+import pytest
 from numpy.testing import assert_allclose
 
 from cogent3 import load_aligned_seqs, make_aligned_seqs, make_table
 from cogent3.core.annotation_db import GffAnnotationDb
-from cogent3.draw.drawable import AnnotatedDrawable, Drawable, get_domain
+from cogent3.draw.drawable import (
+    AnnotatedDrawable,
+    Arrow,
+    Drawable,
+    _calc_arrow_width,
+    get_domain,
+    make_shape,
+)
 from cogent3.util.union_dict import UnionDict
 
 
@@ -391,7 +400,8 @@ class AlignmentDrawablesTests(BaseDrawablesTests):
         db.add_feature(seqid="b", biotype="gene", name="1", spans=[(1, 5)])
         db.add_feature(seqid="b", biotype="gene", name="1", spans=[(5, 1)])
         aln.annotation_db = db
-        aln.get_drawable()
+        g = aln.get_drawable()
+        assert isinstance(g, Drawable)
 
 
 class TableDrawablesTest(BaseDrawablesTests):
@@ -405,5 +415,23 @@ class TableDrawablesTest(BaseDrawablesTests):
         self._check_drawable_attrs(drawable.figure, "table")
 
 
-if __name__ == "__main__":
-    unittest.main()
+def test_calculating_arrow_width_adjusted():
+    aw = _calc_arrow_width(parent_length=100, feature_width=10, frac=1.0)
+    assert aw == 10
+    aw = _calc_arrow_width(parent_length=100, feature_width=10, frac=0.05)
+    assert aw == 5
+
+
+def test_colour_choice():
+    label = str(uuid.uuid4())
+    assert label not in make_shape._colors
+    shape1 = make_shape(type_=label, coords=[(10, 20)])
+    assert label in make_shape._colors
+    shape2 = make_shape(type_=label, coords=[(30, 60)])
+    assert shape1.fillcolor == shape2.fillcolor == make_shape._colors[label]
+
+
+@pytest.mark.parametrize("biotype", ("mRNA", "MRNA", "gene", "misc_RNA", "tRNA", "CDS"))
+def test_arrow_shapes(biotype):
+    shape = make_shape(type_=biotype, coords=[(10, 20)], parent_length=20)
+    assert isinstance(shape, Arrow)
diff --git a/tests/test_parse/test_fasta.py b/tests/test_parse/test_fasta.py
index e47b3c083..aaa75269a 100644
--- a/tests/test_parse/test_fasta.py
+++ b/tests/test_parse/test_fasta.py
@@ -460,6 +460,23 @@ def test_iter_fasta_records_path_types(fasta_path):
     assert len(got) == 1
 
 
+@pytest.fixture(params=("gz", "xz"))
+def fa_gz(DATA_DIR, tmp_path, request):
+    from cogent3 import open_
+
+    path = DATA_DIR / "brca1.fasta"
+    data = path.read_bytes()
+    outpath = tmp_path / f"brca1.fasta.{request.param}"
+    with open_(outpath, "wb") as out:
+        out.write(data)
+    return outpath.as_uri()
+
+
+def test_iter_fasta_records_compressed_file_uri(fa_gz):
+    got = dict(iter_fasta_records(str(fa_gz)))
+    assert len(got) == 55
+
+
 def test_iter_fasta_records_invalid():
     with pytest.raises(TypeError):
         list(iter_fasta_records(numpy.array([">abcd", "ACGG"])))
diff --git a/tests/test_parse/test_sequence.py b/tests/test_parse/test_sequence.py
index c95bf623f..a9cc56acd 100644
--- a/tests/test_parse/test_sequence.py
+++ b/tests/test_parse/test_sequence.py
@@ -64,3 +64,13 @@ def test_line_based_url(DATA_DIR):
     from_url = list(parser(url))
     from_local = list(parser(DATA_DIR / fname))
     assert from_url == from_local
+
+
+@pytest.mark.parametrize("fmt", ("gb", "gbk", "gbff", "genbank"))
+def test_is_genbank(fmt):
+    assert sequence.is_genbank(fmt)
+
+
+@pytest.mark.parametrize("fmt", ("blah", "fa", "xml", "nex", None))
+def test_is_not_genbank(fmt):
+    assert not sequence.is_genbank(fmt)
diff --git a/tests/test_util/test_io.py b/tests/test_util/test_io.py
index 80b417e78..b22eec21c 100644
--- a/tests/test_util/test_io.py
+++ b/tests/test_util/test_io.py
@@ -12,6 +12,7 @@ from cogent3.util.io import (
     _path_relative_to_zip_parent,
     atomic_write,
     get_format_suffixes,
+    is_url,
     iter_line_blocks,
     iter_splitlines,
     open_,
@@ -60,19 +61,19 @@ def test_does_not_write_if_exception(tmp_dir):
     assert not test_filepath.exists()
 
 
-def test_writes_compressed_formats(DATA_DIR, tmp_dir):
+@pytest.mark.parametrize("suffix", ("gz", "bz2", "zip", "lmza", "xz"))
+def test_writes_compressed_formats(DATA_DIR, tmp_dir, suffix):
     """correctly writes / reads different compression formats"""
     fpath = DATA_DIR / "sample.tsv"
     expect = pathlib.Path(fpath).read_text()
-    for suffix in ["gz", "bz2", "zip"]:
-        outpath = tmp_dir / f"{fpath.name}.{suffix}"
-        with atomic_write(outpath, mode="wt") as f:
-            f.write(expect)
+    outpath = tmp_dir / f"{fpath.name}.{suffix}"
+    with atomic_write(outpath, mode="wt") as f:
+        f.write(expect)
 
-        with open_(outpath) as infile:
-            got = infile.read()
+    with open_(outpath) as infile:
+        got = infile.read()
 
-        assert got == expect, f"write failed for {suffix}"
+    assert got == expect, f"write failed for {suffix}"
 
 
 def test_rename(tmp_dir):
@@ -366,7 +367,7 @@ def test_open_url_compressed(DATA_DIR):
     with open_(DATA_DIR / file_name) as infile:
         local_data = infile.read()
 
-    with open_url(remote_root.format(file_name)) as infile:
+    with open_url(remote_root.format(file_name), mode="rt") as infile:
         remote_data = infile.read()
 
     assert remote_data.splitlines() == local_data.splitlines()
@@ -499,3 +500,42 @@ def test_iter_line_blocks_none_num_lines(tmp_path):
     got = list(iter_line_blocks(path, num_lines=None))
     expect = [value]
     assert got == expect
+
+
+@pytest.mark.parametrize(
+    "url",
+    (
+        "http://example.com",
+        b"file://example.txt",
+        pathlib.Path("example.txt").absolute().as_uri(),
+    ),
+)
+def test_is_url(url):
+    assert is_url(url)
+
+
+@pytest.mark.parametrize(
+    "url",
+    ("example.txt", pathlib.Path("example.txt"), b"example.txt", r"D:\foo\example.txt"),
+)
+def test_not_is_url(url):
+    assert not is_url(url)
+
+
+@pytest.fixture
+def gzip_uri(DATA_DIR, tmp_path):
+    inpath = DATA_DIR / "sample.tsv"
+    data = inpath.read_text()
+    outpath = tmp_path / "sample.tsv.gz"
+    with open_(outpath, "wb") as outfile:
+        outfile.write(data.encode("utf8"))
+
+    return outpath.as_uri()
+
+
+@pytest.mark.parametrize("mode", ("r", "rb", "rt"))
+def test_open_url_gzip_mode(gzip_uri, mode):
+    with open_url(gzip_uri, mode=mode) as infile:
+        got = infile.read()
+    expect_type = bytes if "b" in mode else str
+    assert isinstance(got, expect_type)