Repository: cogent3/cogent3
Pull request #2087 (2024-11-01 04:11:01)

MERGE: merging develop into alignment-refactor
## Summary by Sourcery

Merge changes from the develop branch into alignment-refactor, introducing a new `to_regex` method for sequence conversion to regex, enhancing arrow shape handling, and improving URL detection in the io module. Update documentation with a logo and add tests for new features and enhancements.

New Features:
- Introduced a new method `to_regex` in `new_genetic_code` and `new_moltype` to convert sequences into regex patterns.

Enhancements:
- Refactored the `Arrow` class to improve the handling of arrow shapes and sizes in the drawing module.
- Enhanced URL handling in the `io` module by introducing a `singledispatch` function `is_url` to determine if a path is a URL.

Documentation:
- Updated the README.md to include a logo at the top of the document.

Tests:
- Added new tests for the `is_url` function to verify URL detection.
- Introduced tests for the new `to_regex` method in `new_genetic_code` and `new_moltype`.
- Added tests for handling compressed file URIs in FASTA record iteration.


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.d/20241025_082035_Gavin.Huttley.md b/changelog.d/20241025_082035_Gavin.Huttley.md
deleted file mode 100644
index 55794d8f1..000000000
--- a/changelog.d/20241025_082035_Gavin.Huttley.md
+++ /dev/null
@@ -1,41 +0,0 @@
-<!--
-A new scriv changelog fragment.
-
-Uncomment the section that is right (remove the HTML comment wrapper).
--->
-
-<!--
-### Contributors
-
-- A bullet item for the Contributors category.
-
--->
-
-### ENH
-
-- `cogent3.open_()` now automatically handles .xz and .lzma compressed files.
-
-<!--
-### BUG
-
-- A bullet item for the BUG category.
-
--->
-<!--
-### DOC
-
-- A bullet item for the DOC category.
-
--->
-<!--
-### Deprecations
-
-- A bullet item for the Deprecations category.
-
--->
-<!--
-### Discontinued
-
-- A bullet item for the Discontinued category.
-
--->
diff --git a/changelog.d/20241026_143333_Gavin.Huttley.md b/changelog.d/20241026_143333_Gavin.Huttley.md
deleted file mode 100644
index ef0f390a5..000000000
--- a/changelog.d/20241026_143333_Gavin.Huttley.md
+++ /dev/null
@@ -1,42 +0,0 @@
-<!--
-A new scriv changelog fragment.
-
-Uncomment the section that is right (remove the HTML comment wrapper).
--->
-
-<!--
-### Contributors
-
-- A bullet item for the Contributors category.
-
--->
-
-### ENH
-
-- Formatting of fasta records is now much quicker.
-
-
-<!--
-### BUG
-
-- A bullet item for the BUG category.
-
--->
-<!--
-### DOC
-
-- A bullet item for the DOC category.
-
--->
-<!--
-### Deprecations
-
-- A bullet item for the Deprecations category.
-
--->
-<!--
-### Discontinued
-
-- A bullet item for the Discontinued category.
-
--->
diff --git a/changelog.md b/changelog.md
index d7b4a96fd..79813052a 100755
--- a/changelog.md
+++ b/changelog.md
@@ -1,4 +1,13 @@
 
+<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"
 
diff --git a/src/cogent3/_version.py b/src/cogent3/_version.py
index ea988ee8d..3d3862aef 100644
--- a/src/cogent3/_version.py
+++ b/src/cogent3/_version.py
@@ -1,1 +1,1 @@
-__version__ = "2024.7.19a8"
+__version__ = "2024.7.19a9"
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 083a31aae..f74ebf194 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_genetic_code.py b/src/cogent3/core/new_genetic_code.py
index 041fb7b9e..a385dc288 100644
--- a/src/cogent3/core/new_genetic_code.py
+++ b/src/cogent3/core/new_genetic_code.py
@@ -295,6 +295,29 @@ class GeneticCode:
             words=codons, monomers=self.moltype.degen_gapped_alphabet, gap=gap
         )
 
+    def to_regex(self, seq: typing.Union[str, "Sequence"]) -> str:
+        """returns a regex pattern with an amino acid expanded to its codon set
+
+        Parameters
+        ----------
+        seq
+            a Sequence or string of amino acids
+        """
+        from .new_moltype import PROTEIN_WITH_STOP_ambiguities as ambigs
+
+        seq = list(str(seq))
+        mappings = []
+        for aa in seq:
+            aa = ambigs[aa] if aa in ambigs else [aa]
+            codons = []
+            for a in aa:
+                codons.extend(self[a])
+
+            # we create a regex non-capturing group for each amino acid
+            mappings.append(f"(?:{'|'.join(codons)})")
+
+        return "".join(mappings)
+
 
 _mapping_cols = "ncbi_code_sequence", "ID", "name", "ncbi_start_codon_map"
 # code mappings are based on the product of bases in order TCAG
diff --git a/src/cogent3/core/new_moltype.py b/src/cogent3/core/new_moltype.py
index 5fe7d6cc1..9719830a1 100644
--- a/src/cogent3/core/new_moltype.py
+++ b/src/cogent3/core/new_moltype.py
@@ -1169,6 +1169,18 @@ class MolType:
         """returns the most degenerate alphabet for this instance"""
         return next(self.iter_alphabets())
 
+    def to_regex(self, seq: str) -> str:
+        """returns a regex pattern with ambiguities expanded to a character set"""
+        if not self.is_valid(seq):
+            raise ValueError(f"'{seq}' is invalid for this moltype")
+
+        degen_indices = self.get_degenerate_positions(seq=seq, include_gap=False)
+        seq = list(seq)  # seq can now be modified
+        for index in degen_indices:
+            expanded = self.ambiguities[seq[index]]
+            seq[index] = f"[{''.join(sorted(expanded))}]"
+        return "".join(seq)
+
 
 def _make_moltype_dict() -> dict[str, MolType]:
     """make a dictionary of local name space molecular types"""
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/parse/fasta.py b/src/cogent3/parse/fasta.py
index aac43aa96..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,12 +442,13 @@ def _(
 
 @iter_fasta_records.register
 def _(data: str, converter: OptConverterType = None, label_to_name: RenamerType = str):
-    try:
-        os.stat(data)
-    except OSError:
-        raise TypeError(
-            "data is a string but not a file path, directly provided data must be bytes"
-        )
+    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()
diff --git a/src/cogent3/util/io.py b/src/cogent3/util/io.py
index ad33ad1c7..901b4c364 100644
--- a/src/cogent3/util/io.py
+++ b/src/cogent3/util/io.py
@@ -1,4 +1,5 @@
 import contextlib
+import functools
 import shutil
 import uuid
 from bz2 import open as bzip_open
@@ -7,7 +8,6 @@ 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
@@ -19,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(
@@ -73,10 +92,7 @@ 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 TextIOWrapper(opened, encoding=encoding)
+        return opened if binary_mode else TextIOWrapper(opened, encoding=encoding)
 
 
 _compression_handlers = {
@@ -107,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"
@@ -126,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
@@ -138,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)
 
@@ -372,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_new_genetic_code.py b/tests/test_core/test_new_genetic_code.py
index 36498277f..cc946db1a 100644
--- a/tests/test_core/test_new_genetic_code.py
+++ b/tests/test_core/test_new_genetic_code.py
@@ -170,3 +170,22 @@ def test_code_repr(repr_method):
     gc = new_genetic_code.get_code(1)
     got = getattr(gc, repr_method)()
     assert isinstance(got, str)
+
+
+def test_to_regex():
+    """creates a regex from aa seq to match a DNA sequence"""
+    import re
+
+    from cogent3 import make_seq
+
+    dna = "ACCGAACAGGGC"
+    aa = "TEQG"
+    pattern = new_genetic_code.DEFAULT.to_regex(aa)
+    assert "".join(re.findall(pattern, dna)) == dna
+    # note that Z is Q or E
+    aa = "TZQG"
+    pattern = new_genetic_code.DEFAULT.to_regex(aa)
+    assert "".join(re.findall(pattern, dna)) == dna
+    aa = make_seq(aa, moltype="protein", new_type=True)
+    pattern = new_genetic_code.DEFAULT.to_regex(aa)
+    assert "".join(re.findall(pattern, dna)) == dna
diff --git a/tests/test_core/test_new_moltype.py b/tests/test_core/test_new_moltype.py
index a2bbd6217..b311525e8 100644
--- a/tests/test_core/test_new_moltype.py
+++ b/tests/test_core/test_new_moltype.py
@@ -596,3 +596,13 @@ 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)
+
+
+def test_to_regex():
+    """returns a valid regex"""
+    seq = "ACYGR"
+    regular_expression = new_moltype.DNA.to_regex(seq=seq)
+    assert regular_expression == "AC[CT]G[AG]"
+    # raises an exception if a string is already a regex, or invalid
+    with pytest.raises(ValueError):
+        new_moltype.DNA.to_regex("(?:GAT|GAC)(?:GGT|GGC|GGA|GGG)(?:GAT|GAC)(?:CAA|CAG)")
diff --git a/tests/test_draw/test_draw_integration.py b/tests/test_draw/test_draw_integration.py
index 4040b04cb..5a3ca7843 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_util/test_io.py b/tests/test_util/test_io.py
index 3e9949362..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_,
@@ -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)