Repository: cogent3/cogent3
Pull request #1141 (2022-08-29 22:57:43)

Tidy composition checks


Patch:
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index 1619826bb..fbfaebe4f 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -14,6 +14,17 @@ __status__ = "Alpha"
 __all__ = ["align", "composable", "dist", "evo", "io", "sample", "translate", "tree"]
 
 
+def _doc_summary(doc):
+    """return first para of docstring"""
+    result = []
+    for line in doc.splitlines():
+        line = line.strip()
+        if not line:
+            break
+        result.append(line)
+    return " ".join(result)
+
+
 def _get_app_attr(name, is_composable):
     """returns app details for display"""
 
@@ -32,7 +43,7 @@ def _get_app_attr(name, is_composable):
         mod.__name__,
         name,
         is_composable,
-        obj.__doc__,
+        _doc_summary(obj.__doc__ or ""),
         ", ".join(sorted(_types["_data_types"])),
         ", ".join(sorted(_types["_return_types"])),
     ]
diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index 88157e466..9dab9ea3b 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -324,13 +324,10 @@ def pairwise_to_multiple(pwise, ref_seq, moltype, info=None):
 @define_app
 class align_to_ref:
     """Aligns sequences to a nominated reference in the unaligned collection.
+
     This is much faster, and requires much less memory, than progressive_align
     but the quality will likely be lower. Alignment quality will be strongly
     affected by choice of reference.
-
-    Returns
-    -------
-    ArrayAlignment.
     """
 
     def __init__(
@@ -412,8 +409,7 @@ class align_to_ref:
 
 @define_app
 class progressive_align:
-    """Progressive multiple sequence alignment via any cogent3 model.
-    Returns an Alignment object."""
+    """Progressive multiple sequence alignment via any cogent3 model."""
 
     def __init__(
         self,
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index 33e3ec2ea..afe6bf04b 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -123,7 +123,7 @@ class NotCompleted(int):
         return json.dumps(self.to_rich_dict())
 
 
-class ComposableType:
+class ComposableType:  # pragma: no cover
     def __init__(self, input_types=None, output_types=None, data_types=None):
         """
         Parameters
@@ -187,7 +187,7 @@ class ComposableType:
         return valid
 
 
-class Composable(ComposableType):
+class Composable(ComposableType):  # pragma: no cover
     def __init__(self, **kwargs):
         super(Composable, self).__init__(**kwargs)
         self._in = None  # input rules
@@ -505,7 +505,7 @@ class Composable(ComposableType):
         return results
 
 
-class ComposableTabular(Composable):
+class ComposableTabular(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableTabular, self).__init__(**kwargs)
         discontinued(
@@ -516,7 +516,7 @@ class ComposableTabular(Composable):
         )
 
 
-class ComposableSeq(Composable):
+class ComposableSeq(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableSeq, self).__init__(**kwargs)
         discontinued(
@@ -527,7 +527,7 @@ class ComposableSeq(Composable):
         )
 
 
-class ComposableAligned(Composable):
+class ComposableAligned(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableAligned, self).__init__(**kwargs)
         discontinued(
@@ -538,7 +538,7 @@ class ComposableAligned(Composable):
         )
 
 
-class ComposableTree(Composable):
+class ComposableTree(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableTree, self).__init__(**kwargs)
         discontinued(
@@ -549,7 +549,7 @@ class ComposableTree(Composable):
         )
 
 
-class ComposableModel(Composable):
+class ComposableModel(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableModel, self).__init__(**kwargs)
         discontinued(
@@ -560,7 +560,7 @@ class ComposableModel(Composable):
         )
 
 
-class ComposableHypothesis(Composable):
+class ComposableHypothesis(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableHypothesis, self).__init__(**kwargs)
         discontinued(
@@ -571,7 +571,7 @@ class ComposableHypothesis(Composable):
         )
 
 
-class ComposableDistance(Composable):
+class ComposableDistance(Composable):  # pragma: no cover
     def __init__(self, **kwargs):
         super(ComposableDistance, self).__init__(**kwargs)
         discontinued(
@@ -656,7 +656,7 @@ class _checkpointable:
         raise NotImplementedError
 
 
-class user_function(Composable):
+class user_function(Composable):  # pragma: no cover
     """wrapper class for user specified function"""
 
     @extend_docstring_from(ComposableType.__init__, pre=False)
@@ -730,7 +730,7 @@ class user_function(Composable):
 
 
 @extend_docstring_from(ComposableType.__init__, pre=True)
-def appify(input_types, output_types, data_types=None):
+def appify(input_types, output_types, data_types=None):  # pragma: no cover
     """function decorator for generating user apps. Simplifies creation of
     user_function() instances, e.g.
 
@@ -853,18 +853,18 @@ def _disconnect(self):
 
 def _add(self, other):
     if other.input is not None:
-        raise TypeError(
+        raise ValueError(
             f"{other.__class__.__name__} already part of composed function, use disconnect() to free them up"
         )
-    elif other is self:
-        raise TypeError("cannot add an app to itself")
+    if other is self:
+        raise ValueError("cannot add an app to itself")
+
     # Check order
-    elif isinstance(self, user_function) or isinstance(other, user_function):
-        pass
-    elif self.app_type is WRITER:
+    if self.app_type is WRITER:
         raise TypeError("Left hand side of add operator must not be of type writer")
     elif other.app_type is LOADER:
         raise TypeError("Right hand side of add operator must not be of type loader")
+
     if self._return_types & {"SerialisableType", "IdentifierType"}:
         pass
     # validate that self._return_types ia a non-empty set.
@@ -959,6 +959,9 @@ def _validate_data_type(self, data):
     }:
         return True
 
+    if isinstance(data, (tuple, list)):
+        data = data[0]
+
     class_name = data.__class__.__name__
     valid = class_name in self._data_types
     if not valid:
diff --git a/src/cogent3/app/dist.py b/src/cogent3/app/dist.py
index ff35f8058..cdce1f0d7 100644
--- a/src/cogent3/app/dist.py
+++ b/src/cogent3/app/dist.py
@@ -28,7 +28,7 @@ class fast_slow_dist:
     """Pairwise distance calculation for aligned sequences.
 
     Uses fast (but less numerically robust) approach where possible, slow (robust)
-    approach when not. Returns a DistanceMatrix.
+    approach when not.
     """
 
     def __init__(self, distance=None, moltype=None, fast_calc=None, slow_calc=None):
diff --git a/src/cogent3/app/evo.py b/src/cogent3/app/evo.py
index 4f945e46d..fffa362fb 100644
--- a/src/cogent3/app/evo.py
+++ b/src/cogent3/app/evo.py
@@ -39,8 +39,7 @@ __status__ = "Alpha"
 
 @define_app
 class model:
-    """Define a substitution model + tree for maximum likelihood evaluation.
-    Returns model_result."""
+    """Define a substitution model + tree for maximum likelihood evaluation."""
 
     def __init__(
         self,
@@ -382,8 +381,7 @@ class _ModelCollectionBase:
 
 @define_app
 class model_collection(_ModelCollectionBase):
-    """Fits a collection of models. Returns a
-    model_collection_result."""
+    """Fits a collection of models."""
 
     def _make_result(self, aln: AlignedSeqsType) -> ModelCollectionResultType:
         return model_collection_result(source=aln.info)
@@ -391,8 +389,7 @@ class model_collection(_ModelCollectionBase):
 
 @define_app
 class hypothesis(_ModelCollectionBase):
-    """Specify a hypothesis through defining two models. Returns a
-    hypothesis_result."""
+    """Specify a hypothesis through defining two models."""
 
     def _make_result(self, aln: AlignedSeqsType) -> HypothesisResultType:
         return hypothesis_result(name_of_null=self.null.name, source=aln.info)
@@ -400,7 +397,7 @@ class hypothesis(_ModelCollectionBase):
 
 @define_app
 class bootstrap:
-    """Parametric bootstrap for a provided hypothesis. Returns a bootstrap_result."""
+    """Parametric bootstrap for a provided hypothesis."""
 
     def __init__(self, hyp, num_reps, parallel=False, verbose=False):
         self._hyp = hyp
@@ -446,8 +443,7 @@ class bootstrap:
 
 @define_app
 class ancestral_states:
-    """Computes ancestral state probabilities from a model result. Returns a dict
-    with a DictArray for each node."""
+    """Computes ancestral state probabilities from a model result."""
 
     def main(
         self, result: ModelResultType
@@ -943,7 +939,7 @@ class natsel_sitehet:
 class natsel_timehet:
     """The branch heterogeneity hypothesis test for natural selection.
     Tests for whether a single omega for all branches is sufficient against the
-    alternate that a user specified subset of branches have a distinct value
+    alternate that a user specified subset of branches has a distinct value
     (or values) of omega.
     """
 
diff --git a/src/cogent3/app/sample.py b/src/cogent3/app/sample.py
index 7a2d9b510..b70dae5df 100644
--- a/src/cogent3/app/sample.py
+++ b/src/cogent3/app/sample.py
@@ -42,7 +42,7 @@ def union(groups):
 
 @define_app(composable=False)
 class concat:
-    """Creates a concatenated alignment from a series. Returns an Alignment."""
+    """Creates a concatenated alignment from a series."""
 
     def __init__(self, join_seq="", intersect=True, moltype=None):
         """concatenate sequences from a series of alignments
@@ -108,7 +108,7 @@ class concat:
 @define_app
 class omit_degenerates:
     """Excludes alignment columns with degenerate conditions. Can accomodate
-    reading frame. Returns modified Alignment."""
+    reading frame."""
 
     def __init__(self, moltype=None, gap_is_degen=True, motif_length=1):
         """excludes degenerate characters from alignment
@@ -147,7 +147,7 @@ class omit_degenerates:
 @define_app
 class omit_gap_pos:
     """Excludes gapped alignment columns meeting a threshold. Can accomodate
-    reading frame. Returns modified Alignment."""
+    reading frame."""
 
     def __init__(self, allowed_frac=0.99, motif_length=1, moltype=None):
         """
@@ -185,8 +185,7 @@ class omit_gap_pos:
 
 @define_app
 class take_codon_positions:
-    """Extracts the specified codon position(s) from an alignment.
-    Returns an Alignment."""
+    """Extracts the specified codon position(s) from an alignment."""
 
     def __init__(
         self,
@@ -280,8 +279,7 @@ class take_codon_positions:
 
 @define_app
 class take_named_seqs:
-    """Extracts (or everything but) named sequences. Returns a filtered
-    sequences, alignment that satisified the condition, NotCompleted otherwise."""
+    """Extracts named sequences."""
 
     def __init__(self, *names, negate=False):
         """selects named sequences from a collection
@@ -308,10 +306,8 @@ class take_named_seqs:
 
 @define_app
 class take_n_seqs:
-    """Selects n sequences from a collection. Chooses first n sequences, or selects randomly if specified.
-
-    Returns original object type with the selected sequences, NotCompleted object otherwise.
-    """
+    """Selects n sequences from a collection. Chooses first n sequences, or
+    selects randomly if specified."""
 
     def __init__(self, number, random=False, seed=None, fixed_choice=True):
         """
@@ -370,8 +366,7 @@ class take_n_seqs:
 
 @define_app
 class min_length:
-    """Filters sequence collections / alignments by length. Returns the
-    data if it satisfies the condition, NotCompleted otherwise."""
+    """Filters sequence collections / alignments by length."""
 
     def __init__(self, length, motif_length=1, subtract_degen=True, moltype=None):
         """
@@ -439,8 +434,7 @@ class _GetStart:
 
 @define_app
 class fixed_length:
-    """Sample an alignment to a fixed length. Returns an Alignment of the
-    specified length, or NotCompleted if alignment too short."""
+    """Sample an alignment to a fixed length."""
 
     def __init__(
         self, length, start=0, random=False, seed=None, motif_length=1, moltype=None
@@ -534,8 +528,7 @@ class fixed_length:
 
 @define_app
 class omit_bad_seqs:
-    """Eliminates sequences from Alignment based on gap fraction, unique gaps.
-    Returns modified alignment."""
+    """Eliminates sequences from Alignment based on gap fraction, unique gaps."""
 
     def __init__(self, quantile=None, gap_fraction=1, moltype="dna"):
         """Returns an alignment without the sequences responsible for
@@ -581,7 +574,7 @@ class omit_bad_seqs:
 @define_app
 class omit_duplicated:
     """Removes redundant sequences, recording dropped sequences in
-    seqs.info.dropped. Returns sequence collection with only unique sequences."""
+    seqs.info.dropped."""
 
     def __init__(self, mask_degen=False, choose="longest", seed=None, moltype=None):
         """Returns unique sequences, adds 'dropped' key to seqs.info
@@ -662,7 +655,7 @@ class omit_duplicated:
 
 @define_app
 class trim_stop_codons:
-    """Removes terminal stop codons. Returns sequences / alignment."""
+    """Removes terminal stop codons."""
 
     def __init__(self, gc=1):
         """selects named sequences from a collection
diff --git a/src/cogent3/app/translate.py b/src/cogent3/app/translate.py
index a0d5724a5..866982b21 100644
--- a/src/cogent3/app/translate.py
+++ b/src/cogent3/app/translate.py
@@ -151,8 +151,7 @@ def get_fourfold_degenerate_sets(gc, alphabet=None, as_indices=True):
 
 @define_app
 class select_translatable:
-    """Identifies most likely reading frame. Returns modified sequences / alignment,
-    if it could be resolved, NotCompleted otherwise."""
+    """Identifies most likely reading frame."""
 
     def __init__(self, moltype="dna", gc=1, allow_rc=False, trim_terminal_stop=True):
         """selects translatable sequences
diff --git a/src/cogent3/app/tree.py b/src/cogent3/app/tree.py
index c412e1752..fd861ec25 100644
--- a/src/cogent3/app/tree.py
+++ b/src/cogent3/app/tree.py
@@ -19,8 +19,7 @@ __status__ = "Alpha"
 
 @define_app
 class scale_branches:
-    """Transforms tree branch lengths from nucleotide to codon, or the converse.
-    Returns a Tree."""
+    """Transforms tree branch lengths from nucleotide to codon, or the converse."""
 
     def __init__(self, nuc_to_codon=None, codon_to_nuc=None, scalar=1, min_length=1e-6):
         """returns a new tree with lengths divided by scalar
@@ -65,7 +64,7 @@ class scale_branches:
 
 @define_app
 class uniformize_tree:
-    """Standardises the orientation of unrooted trees. Returns a Tree."""
+    """Standardises the orientation of unrooted trees."""
 
     def __init__(self, root_at="midpoint", ordered_names=None):
         """returns a new tree with standardised orientation
diff --git a/src/cogent3/app/typing.py b/src/cogent3/app/typing.py
index 696cadbf2..6d0f7166f 100644
--- a/src/cogent3/app/typing.py
+++ b/src/cogent3/app/typing.py
@@ -86,7 +86,7 @@ def get_constraint_names(*hints) -> set[str, ...]:
             all_hints.update(hint.__constraints__)
             continue
 
-        if get_origin(hint) == Union:
+        if get_origin(hint) in (Union, list, tuple):
             all_hints.update(get_constraint_names(*get_args(hint)))
 
         if type(hint) == type:


Test patch:
diff --git a/tests/test_app/test_composable.py b/tests/test_app/test_composable.py
index c979f661e..bcf33200f 100644
--- a/tests/test_app/test_composable.py
+++ b/tests/test_app/test_composable.py
@@ -102,7 +102,7 @@ def test_composables_once():
     two = app_dummyclass_2(2)
     three = app_dummyclass_3(3)
     one + three
-    with pytest.raises(TypeError):
+    with pytest.raises(ValueError):
         two + three  # three already has an input
 
     __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
@@ -122,7 +122,7 @@ def test_composable_to_self():
             return val
 
     app1 = app_dummyclass_1(1)
-    with pytest.raises(TypeError):
+    with pytest.raises(ValueError):
         _ = app1 + app1
 
     __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
@@ -440,6 +440,10 @@ def bar(val: AlignedSeqsType, num=3) -> PairwiseDistanceType:
     return val.distance_matrix(calc="hamming", show_progress=False)
 
 
+for _app_ in (foo, bar):
+    __app_registry.pop(get_object_provenance(_app_), None)
+
+
 def test_user_function():
     """composable functions should be user definable"""
     from cogent3 import make_aligned_seqs
@@ -451,8 +455,6 @@ def test_user_function():
 
     assert got.to_dict() == {"a": "GCAA", "b": "GCTT"}
 
-    __app_registry.pop(get_object_provenance(foo), None)
-
 
 def test_user_function_multiple():
     """user defined composable functions should not interfere with each other"""
@@ -471,9 +473,6 @@ def test_user_function_multiple():
     assert got_1.to_dict() == {"a": "GCAA", "b": "GCTT"}
     assert got_2 == {("s1", "s2"): 2.0, ("s2", "s1"): 2.0}
 
-    __app_registry.pop(get_object_provenance(foo), None)
-    __app_registry.pop(get_object_provenance(bar), None)
-
 
 def test_appify():
     """acts like a decorator should!"""
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index 16d01268f..b60cf49e5 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -117,16 +117,14 @@ class TestAvailableApps(TestCase):
                 and not app1._return_types & {"SerialisableType", "IdentifierType"}
             ]
 
-            for incompatible_application_tuple in incompatible_application_tuples:
-                incompatible_application_tuple[0].disconnect()
-                incompatible_application_tuple[1].disconnect()
+            for app_a, app_b in incompatible_application_tuples:
+                err_type = ValueError if app_a is app_b else TypeError
+                app_a.disconnect()
+                app_b.disconnect()
 
                 # Compose two incompatible applications, there should be exceptions.
-                with self.assertRaises(TypeError):
-                    res = (
-                        incompatible_application_tuple[0]
-                        + incompatible_application_tuple[1]
-                    )
+                with self.assertRaises(err_type):
+                    app_a + app_b
 
             for app in applications:
                 if hasattr(app, "data_store"):
diff --git a/tests/test_app/test_typing.py b/tests/test_app/test_typing.py
index a18c799b4..f3be0b668 100644
--- a/tests/test_app/test_typing.py
+++ b/tests/test_app/test_typing.py
@@ -1,3 +1,5 @@
+from typing import List, Tuple
+
 import pytest
 
 from cogent3.app.typing import (
@@ -87,3 +89,9 @@ def test_hints_from_strings_invalid():
 def test_hints_from_strings():
     got = hints_from_strings("serialisable", "aligned")
     assert got == [SerialisableType, AlignedSeqsType]
+
+
+@pytest.mark.parametrize("container", (List, Tuple))
+def test_hints_from_container_type(container):
+    got = get_constraint_names(container[AlignedSeqsType])
+    assert got == {"Alignment", "ArrayAlignment"}