Repository: cogent3/cogent3
Pull request #1114 (2022-08-13 16:13:43)

Composable refactor


Patch:
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index ff9c37a5a..60b392ca6 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -14,52 +14,43 @@ __status__ = "Alpha"
 __all__ = ["align", "composable", "dist", "evo", "io", "sample", "translate", "tree"]
 
 
-def _get_app_attr(name, obj, mod, is_composable):
+def _get_app_attr(name, is_composable):
     """returns app details for display"""
 
-    _types = {"_input_types": [], "_output_types": [], "_data_types": []}
+    modname, name = name.rsplit(".", maxsplit=1)
+    mod = importlib.import_module(modname)
+    obj = getattr(mod, name)
 
-    for tys in _types.keys():
+    _types = {"_data_types": [], "_return_types": []}
+
+    for tys in _types:
         types = getattr(obj, tys, None) or []
         types = [types] if type(types) == str else types
         _types[tys] = [{None: ""}.get(e, e) for e in types]
 
-    row = [
+    return [
         mod.__name__,
         name,
         is_composable,
         obj.__doc__,
-        ", ".join(_types["_input_types"]),
-        ", ".join(_types["_output_types"]),
-        ", ".join(_types["_data_types"]),
+        _types["_data_types"],
+        _types["_return_types"],
     ]
-    return row
 
 
 def available_apps():
     """returns Table listing the available apps"""
     from cogent3.util.table import Table
 
-    from .composable import Composable, user_function
+    from .composable import Composable, __app_registry, user_function
 
-    # excluding composable, find all class
-    rows = []
-    for m in __all__:
-        if m == "composable":
-            continue
-        mod = importlib.import_module(f"{__name__}.{m}")
-        for name, obj in inspect.getmembers(mod, inspect.isclass):
-            if name.startswith("_"):
-                continue
-            if obj.__module__ == mod.__name__:
-                is_composable = issubclass(obj, Composable)
-                rows.append(_get_app_attr(name, obj, mod, is_composable))
+    rows = [_get_app_attr(app, True) for app in __app_registry]
 
     mod = importlib.import_module(f"{__name__}.composable")
     rows.append(
         _get_app_attr(
-            "user_function", user_function, mod, issubclass(user_function, Composable)
+            f"{mod.__name__}.user_function", issubclass(user_function, Composable)
         )
     )
-    header = ["module", "name", "composable", "doc", "inputs", "outputs", "data type"]
+    header = ["module", "name", "composable", "doc", "outputs", "data type"]
     return Table(header, rows)
diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index 12d68ae58..c19a75983 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -16,7 +16,7 @@ from cogent3.core.location import gap_coords_to_map
 from cogent3.core.moltype import get_moltype
 from cogent3.evolve.models import get_model
 
-from .composable import NotCompleted, composable
+from .composable import NotCompleted, define_app
 from .tree import quick_tree, scale_branches
 from .typing import (
     AlignedSeqsType,
@@ -326,7 +326,7 @@ def pairwise_to_multiple(pwise, ref_seq, moltype, info=None):
     )
 
 
-@composable
+@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
@@ -415,7 +415,7 @@ class align_to_ref:
         return self._func(seqs)
 
 
-@composable
+@define_app
 class progressive_align:
     """Progressive multiple sequence alignment via any cogent3 model.
     Returns an Alignment object."""
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index f85734d9c..5ab97a2ae 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -954,7 +954,7 @@ class _connected:
 __app_registry = {}
 
 
-def composable(klass=None, *, app_type: AppType = GENERIC):
+def define_app(klass=None, *, app_type: AppType = GENERIC, composable: bool = True):
     app_type = AppType(app_type)
 
     def wrapped(klass):
@@ -964,38 +964,24 @@ def composable(klass=None, *, app_type: AppType = GENERIC):
         # check if user defined these functions
         method_list = [
             "__call__",
-            "__add__",
-            "input",
             "__repr__",
             "__str__",
             "__new__",
-            "disconnect",
-            "apply_to",
         ]
+        if composable:
+            method_list.extend(("__add__", "input", "disconnect", "apply_to"))
+
         for meth in method_list:
-            if inspect.isfunction(getattr(klass, meth, None)):
+            if composable and inspect.isfunction(getattr(klass, meth, None)):
                 raise TypeError(
                     f"remove {meth!r} method in {klass.__name__!r}, this functionality provided by composable"
                 )
-        if getattr(klass, "input", None):
+        if composable and getattr(klass, "input", None):
             raise TypeError(
                 f"remove 'input' attribute in {klass.__name__!r}, this functionality provided by composable"
             )
 
-        _mapping = {
-            "__new__": _new,
-            "__add__": _add,
-            "__call__": _call,
-            "__repr__": _repr,  # str(obj) calls __repr__ if __str__ missing
-            "__getstate__": _getstate,
-            "__setstate__": _setstate,
-            "_serialisable": _ser,
-            "_validate_data_type": _validate_data_type,
-            "disconnect": _disconnect,
-            "apply_to": _apply_to,
-        }
-
-        for meth, func in _mapping.items():
+        for meth, func in __mapping.items():
             # check if the developer implements a __getstate__ or
             # __setstate__ don't introduce our own.
             if (
@@ -1023,7 +1009,8 @@ def composable(klass=None, *, app_type: AppType = GENERIC):
             klass.__slots__ += tuple(slot_attrs)
 
         # register this app
-        __app_registry[get_object_provenance(klass)] = klass.__name__
+        __app_registry[get_object_provenance(klass)] = composable
+
         return klass
 
     return wrapped(klass) if klass else wrapped
@@ -1031,7 +1018,7 @@ def composable(klass=None, *, app_type: AppType = GENERIC):
 
 def is_composable(obj):
     """checks whether obj has been registered by the composable decorator"""
-    return get_object_provenance(obj) in __app_registry
+    return __app_registry.get(get_object_provenance(obj), False)
 
 
 def _apply_to(
@@ -1199,3 +1186,17 @@ def _apply_to(
         self = process + self
 
     return results
+
+
+__mapping = {
+    "__new__": _new,
+    "__add__": _add,
+    "__call__": _call,
+    "__repr__": _repr,  # str(obj) calls __repr__ if __str__ missing
+    "__getstate__": _getstate,
+    "__setstate__": _setstate,
+    "_serialisable": _ser,
+    "_validate_data_type": _validate_data_type,
+    "disconnect": _disconnect,
+    "apply_to": _apply_to,
+}
diff --git a/src/cogent3/app/dist.py b/src/cogent3/app/dist.py
index 3f0f9eb85..41b6633ae 100644
--- a/src/cogent3/app/dist.py
+++ b/src/cogent3/app/dist.py
@@ -9,7 +9,7 @@ from cogent3.evolve.fast_distance import (
 )
 from cogent3.evolve.models import get_model
 
-from .composable import composable
+from .composable import define_app
 from .typing import AlignedSeqsType, PairwiseDistanceType, SerialisableType
 
 
@@ -23,7 +23,7 @@ __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Alpha"
 
 
-@composable
+@define_app
 class fast_slow_dist:
     """Pairwise distance calculation for aligned sequences.
 
diff --git a/src/cogent3/app/evo.py b/src/cogent3/app/evo.py
index a163c1cfd..41bb06d59 100644
--- a/src/cogent3/app/evo.py
+++ b/src/cogent3/app/evo.py
@@ -8,7 +8,7 @@ from cogent3.core.tree import TreeNode
 from cogent3.evolve.models import get_model
 from cogent3.util import parallel
 
-from .composable import NotCompleted, composable
+from .composable import NotCompleted, define_app
 from .result import (
     bootstrap_result,
     hypothesis_result,
@@ -16,7 +16,13 @@ from .result import (
     model_result,
     tabular_result,
 )
-from .typing import AlignedSeqsType, ModelResultType, SerialisableType
+from .typing import (
+    AlignedSeqsType,
+    BootstrapResultType,
+    ModelResultType,
+    SerialisableType,
+    TabularResultType,
+)
 
 
 __author__ = "Gavin Huttley"
@@ -29,7 +35,7 @@ __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Alpha"
 
 
-@composable
+@define_app
 class model:
     """Define a substitution model + tree for maximum likelihood evaluation.
     Returns model_result."""
@@ -223,7 +229,7 @@ class model:
 
     def main(
         self, aln: AlignedSeqsType, initialise=None, construct=True, **opt_args
-    ) -> Union[SerialisableType, AlignedSeqsType]:
+    ) -> Union[SerialisableType, ModelResultType]:
         moltypes = {aln.moltype.label, self._sm.moltype.label}
         if moltypes in [{"protein", "dna"}, {"protein", "rna"}]:
             msg = f"substitution model moltype '{self._sm.moltype.label}' and alignment moltype '{aln.moltype.label}' are incompatible"
@@ -286,7 +292,7 @@ class _InitFrom:
         return other
 
 
-@composable
+@define_app
 class model_collection:
     """Fits a collection of models. Returns a
     model_collection_result."""
@@ -348,7 +354,7 @@ class model_collection:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         try:
             null = self.null(aln)
         except ValueError:
@@ -385,7 +391,7 @@ class hypothesis(model_collection):
         return hypothesis_result(name_of_null=self.null.name, source=aln.info)
 
 
-@composable
+@define_app
 class bootstrap:
     """Parametric bootstrap for a provided hypothesis. Returns a bootstrap_result."""
 
@@ -405,9 +411,9 @@ class bootstrap:
             sym_result = None
         return sym_result
 
-    T = Union[SerialisableType, AlignedSeqsType]
+    T = Union[SerialisableType, BootstrapResultType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         result = bootstrap_result(aln.info.source)
         try:
             obs = self._hyp(aln)
@@ -431,7 +437,7 @@ class bootstrap:
         return result
 
 
-@composable
+@define_app
 class ancestral_states:
     """Computes ancestral state probabilities from a model result. Returns a dict
     with a DictArray for each node."""
@@ -443,7 +449,7 @@ class ancestral_states:
 
     T = Union[SerialisableType, ModelResultType]
 
-    def main(self, result: T) -> T:
+    def main(self, result: ModelResultType) -> T:
         """returns a tabular_result of posterior probabilities of ancestral states"""
         anc = result.lf.reconstruct_ancestral_seqs()
         fl = result.lf.get_full_length_likelihoods()
@@ -458,16 +464,13 @@ class ancestral_states:
         return tab
 
 
-@composable
+@define_app
 class tabulate_stats:
     """Extracts all model statistics from model_result as Table."""
 
-    def __init__(self):
-        pass
-
-    T = Union[SerialisableType, ModelResultType]
-
-    def main(self, result: T) -> T:
+    def main(
+        self, result: ModelResultType
+    ) -> Union[SerialisableType, TabularResultType]:
         """returns Table for all statistics returned by likelihood function
         get_statistics"""
         stats = result.lf.get_statistics(with_titles=True, with_motif_probs=True)
@@ -486,7 +489,7 @@ def is_codon_model(sm):
     return isinstance(sm, _Codon)
 
 
-@composable
+@define_app
 class natsel_neutral:
     """Test of selective neutrality by assessing whether omega equals 1.
     Under the alternate, there is one omega for all branches and all sites.
@@ -581,11 +584,11 @@ class natsel_neutral:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: AlignedSeqsType) -> T:
         return self._hyp(data)
 
 
-@composable
+@define_app
 class natsel_zhang:
     """The branch by site-class hypothesis test for natural selection of
     Zhang et al MBE 22: 2472-2479.
@@ -767,7 +770,7 @@ class natsel_zhang:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T, *args, **kwargs) -> T:
+    def main(self, aln: AlignedSeqsType, *args, **kwargs) -> T:
         null_result = self.null(aln)
         if not null_result:
             return null_result
@@ -784,7 +787,7 @@ class natsel_zhang:
         return result
 
 
-@composable
+@define_app
 class natsel_sitehet:
     """Test for site-heterogeneity in omega. Under null, there are 2 site-classes,
     omega < 1 and omega = 1. Under the alternate, an additional site-class of
@@ -915,9 +918,9 @@ class natsel_sitehet:
         alt_args["param_rules"] = rules
         return model(**alt_args)
 
-    T = Union[SerialisableType, AlignedSeqsType]
+    T = Union[SerialisableType, ModelResultType]
 
-    def main(self, aln: T, *args, **kwargs) -> T:
+    def main(self, aln: AlignedSeqsType, *args, **kwargs) -> T:
         null_result = self.null(aln)
         if not null_result:
             return null_result
@@ -934,7 +937,7 @@ class natsel_sitehet:
         return result
 
 
-@composable
+@define_app
 class natsel_timehet:
     """The branch heterogeneity hypothesis test for natural selection.
     Tests for whether a single omega for all branches is sufficient against the
@@ -1081,5 +1084,5 @@ class natsel_timehet:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: AlignedSeqsType) -> T:
         return self._hyp(data)
diff --git a/src/cogent3/app/io.py b/src/cogent3/app/io.py
index 856210ce6..ec23fd33e 100644
--- a/src/cogent3/app/io.py
+++ b/src/cogent3/app/io.py
@@ -20,7 +20,7 @@ from cogent3.parse.sequence import PARSERS
 from cogent3.util.deserialise import deserialise_object
 from cogent3.util.table import Table
 
-from .composable import NotCompleted, _checkpointable, composable
+from .composable import NotCompleted, _checkpointable, define_app
 from .data_store import (
     IGNORE,
     OVERWRITE,
@@ -39,10 +39,10 @@ from .typing import (
     ALIGNED_TYPE,
     IDENTIFIER_TYPE,
     SEQUENCE_TYPE,
-    TABULAR_TYPE,
     AlignedSeqsType,
     IdentifierType,
     SerialisableType,
+    TabularType,
     UnalignedSeqsType,
 )
 
@@ -185,7 +185,7 @@ def _load_seqs(path, klass, parser, moltype):
     return seqs
 
 
-@composable
+@define_app
 class load_aligned:
     """Loads aligned sequences. Returns an Alignment object."""
 
@@ -212,7 +212,7 @@ class load_aligned:
         return _load_seqs(path, self.klass, self._parser, self.moltype)
 
 
-@composable
+@define_app
 class load_unaligned:
     """Loads unaligned sequences. Returns a SequenceCollection."""
 
@@ -240,7 +240,7 @@ class load_unaligned:
         return seqs.degap()
 
 
-@composable
+@define_app
 class load_tabular:
     """Loads delimited data. Returns a Table."""
 
@@ -321,7 +321,7 @@ class load_tabular:
         records = numpy.array(records, dtype="O").T
         return header, records, title
 
-    def main(self, path: IdentifierType) -> TABULAR_TYPE:
+    def main(self, path: IdentifierType) -> TabularType:
         if type(path) == str:
             # we use a data store as it's read() handles compression
             path = SingleReadDataStore(path)[0]
@@ -350,7 +350,7 @@ class load_tabular:
         return None
 
 
-@composable
+@define_app
 class write_tabular:
     """writes tabular data"""
 
@@ -376,7 +376,7 @@ class write_tabular:
         """
         self._format = format
 
-    def main(self, data: TABULAR_TYPE, identifier=None) -> IdentifierType:
+    def main(self, data: TabularType, identifier=None) -> IdentifierType:
         if isinstance(data, NotCompleted):
             return self.data_store.write_incomplete(identifier, data)
 
@@ -470,7 +470,7 @@ class write_seqs(_checkpointable):
         return stored
 
 
-@composable
+@define_app
 class load_json:
     """Loads json serialised cogent3 objects from a json file.
     Returns whatever object type was stored."""
@@ -498,7 +498,7 @@ class load_json:
         return result
 
 
-@composable
+@define_app
 class write_json:
     """Writes json serialised objects to individual json files."""
 
@@ -538,7 +538,7 @@ class write_json:
         return stored
 
 
-@composable
+@define_app
 class load_db:
     """Loads json serialised cogent3 objects from a TinyDB file.
     Returns whatever object type was stored."""
@@ -567,7 +567,7 @@ class load_db:
         return result
 
 
-@composable
+@define_app
 class write_db:
     """Writes json serialised objects to a TinyDB instance."""
 
diff --git a/src/cogent3/app/sample.py b/src/cogent3/app/sample.py
index 77dd080e6..aa890b6c5 100644
--- a/src/cogent3/app/sample.py
+++ b/src/cogent3/app/sample.py
@@ -1,5 +1,5 @@
 from collections import defaultdict
-from typing import Union
+from typing import List, Union
 
 from numpy import array
 from numpy import random as np_random
@@ -8,7 +8,7 @@ from cogent3.core.alignment import Alignment, ArrayAlignment
 from cogent3.core.genetic_code import get_code
 from cogent3.core.moltype import get_moltype
 
-from .composable import NotCompleted, composable
+from .composable import NotCompleted, define_app
 from .translate import get_fourfold_degenerate_sets
 from .typing import AlignedSeqsType, SeqsCollectionType, SerialisableType
 
@@ -40,6 +40,7 @@ def union(groups):
     return union
 
 
+@define_app(composable=False)
 class concat:
     """Creates a concatenated alignment from a series. Returns an Alignment."""
 
@@ -61,7 +62,9 @@ class concat:
         self._moltype = moltype
         self._join_seq = join_seq
 
-    def concat(self, data):
+    def main(
+        self, data: List[AlignedSeqsType]
+    ) -> Union[SerialisableType, AlignedSeqsType]:
         """returns an alignment
 
         Parameters
@@ -101,10 +104,8 @@ class concat:
         aln = ArrayAlignment(data=combined, moltype=self._moltype)
         return aln
 
-    __call__ = concat
 
-
-@composable
+@define_app
 class omit_degenerates:
     """Excludes alignment columns with degenerate conditions. Can accomodate
     reading frame. Returns modified Alignment."""
@@ -134,7 +135,7 @@ class omit_degenerates:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         if self.moltype and aln.moltype != self.moltype:
             # try converting
             aln = aln.to_moltype(self.moltype)
@@ -144,7 +145,7 @@ class omit_degenerates:
         ) or NotCompleted("FAIL", self, "all columns contained degenerates", source=aln)
 
 
-@composable
+@define_app
 class omit_gap_pos:
     """Excludes gapped alignment columns meeting a threshold. Can accomodate
     reading frame. Returns modified Alignment."""
@@ -171,7 +172,7 @@ class omit_gap_pos:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         if self.moltype and aln.moltype != self.moltype:
             # try converting
             aln = aln.to_moltype(self.moltype)
@@ -183,7 +184,7 @@ class omit_gap_pos:
         )
 
 
-@composable
+@define_app
 class take_codon_positions:
     """Extracts the specified codon position(s) from an alignment.
     Returns an Alignment."""
@@ -274,11 +275,11 @@ class take_codon_positions:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         return self._func(aln)
 
 
-@composable
+@define_app
 class take_named_seqs:
     """Extracts (or everything but) named sequences. Returns a filtered
     sequences, alignment that satisified the condition, NotCompleted otherwise."""
@@ -296,7 +297,7 @@ class take_named_seqs:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: SeqsCollectionType) -> T:
         try:
             data = data.take_seqs(self._names, negate=self._negate)
         except KeyError:
@@ -306,7 +307,7 @@ class take_named_seqs:
         return data
 
 
-@composable
+@define_app
 class take_n_seqs:
     """Selects n sequences from a collection. Chooses first n sequences, or selects randomly if specified.
 
@@ -351,7 +352,7 @@ class take_n_seqs:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: SeqsCollectionType) -> T:
         """returns data with n sequences"""
         if len(data.names) < self._number:
             return NotCompleted("FALSE", self.main, "not enough sequences")
@@ -368,7 +369,7 @@ class take_n_seqs:
         return data
 
 
-@composable
+@define_app
 class min_length:
     """Filters sequence collections / alignments by length. Returns the
     data if it satisfies the condition, NotCompleted otherwise."""
@@ -397,7 +398,7 @@ class min_length:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: SeqsCollectionType) -> T:
         if self._moltype and self._moltype != data.moltype:
             data = data.to_moltype(self._moltype)
 
@@ -437,7 +438,7 @@ class _GetStart:
         return self.func(length)
 
 
-@composable
+@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."""
@@ -527,12 +528,12 @@ class fixed_length:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: AlignedSeqsType) -> T:
         """return a fixed length alignment"""
         return self._func(data)
 
 
-@composable
+@define_app
 class omit_bad_seqs:
     """Eliminates sequences from Alignment based on gap fraction, unique gaps.
     Returns modified alignment."""
@@ -565,7 +566,7 @@ class omit_bad_seqs:
 
     T = Union[SerialisableType, AlignedSeqsType]
 
-    def main(self, aln: T) -> T:
+    def main(self, aln: AlignedSeqsType) -> T:
         if self._moltype and self._moltype != aln.moltype:
             aln = aln.to_moltype(self._moltype)
 
@@ -578,7 +579,7 @@ class omit_bad_seqs:
         return result
 
 
-@composable
+@define_app
 class omit_duplicated:
     """Removes redundant sequences, recording dropped sequences in
     seqs.info.dropped. Returns sequence collection with only unique sequences."""
@@ -656,11 +657,11 @@ class omit_duplicated:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, seqs: T) -> T:
+    def main(self, seqs: SeqsCollectionType) -> T:
         return self._func(seqs)
 
 
-@composable
+@define_app
 class trim_stop_codons:
     """Removes terminal stop codons. Returns sequences / alignment."""
 
@@ -682,6 +683,6 @@ class trim_stop_codons:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, data: T) -> T:
+    def main(self, data: SeqsCollectionType) -> T:
         data = data.trim_stop_codons(gc=self._gc)
         return data
diff --git a/src/cogent3/app/translate.py b/src/cogent3/app/translate.py
index 5013ac707..a19d90409 100644
--- a/src/cogent3/app/translate.py
+++ b/src/cogent3/app/translate.py
@@ -5,7 +5,7 @@ from cogent3.core.alignment import SequenceCollection
 from cogent3.core.genetic_code import get_code
 from cogent3.core.moltype import get_moltype
 
-from .composable import NotCompleted, composable
+from .composable import NotCompleted, define_app
 from .typing import SeqsCollectionType, SerialisableType, UnalignedSeqsType
 
 
@@ -149,7 +149,7 @@ def get_fourfold_degenerate_sets(gc, alphabet=None, as_indices=True):
     return four_fold
 
 
-@composable
+@define_app
 class select_translatable:
     """Identifies most likely reading frame. Returns modified sequences / alignment,
     if it could be resolved, NotCompleted otherwise."""
@@ -187,7 +187,7 @@ class select_translatable:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, seqs: T) -> T:
+    def main(self, seqs: SeqsCollectionType) -> T:
         """returns the translatable sequences from seqs.
 
         translation errors are stroed in the info object"""
@@ -227,7 +227,7 @@ class select_translatable:
         return translatable
 
 
-@composable
+@define_app
 class translate_seqs:
     """Translates sequences, assumes in correct reading frame."""
 
@@ -257,7 +257,7 @@ class translate_seqs:
 
     T = Union[SerialisableType, SeqsCollectionType]
 
-    def main(self, seqs: T) -> T:
+    def main(self, seqs: SeqsCollectionType) -> T:
         """returns translated sequences"""
         if self._moltype and self._moltype != seqs.moltype:
             seqs = seqs.to_moltype(self._moltype)
diff --git a/src/cogent3/app/tree.py b/src/cogent3/app/tree.py
index 330a1d8bd..074738f2f 100644
--- a/src/cogent3/app/tree.py
+++ b/src/cogent3/app/tree.py
@@ -3,7 +3,7 @@ from typing import Union
 from cogent3 import make_tree
 from cogent3.phylo.nj import gnj
 
-from .composable import Composable, composable
+from .composable import Composable, define_app
 from .typing import (
     PAIRWISE_DISTANCE_TYPE,
     SERIALISABLE_TYPE,
@@ -25,7 +25,7 @@ __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Alpha"
 
 
-@composable
+@define_app
 class scale_branches:
     """Transforms tree branch lengths from nucleotide to codon, or the converse.
     Returns a Tree."""
@@ -71,7 +71,7 @@ class scale_branches:
         return tree
 
 
-@composable
+@define_app
 class uniformize_tree:
     """Standardises the orientation of unrooted trees. Returns a Tree."""
 
@@ -101,7 +101,7 @@ class uniformize_tree:
         return new
 
 
-@composable
+@define_app
 class quick_tree:
     """Neighbour Joining tree based on pairwise distances."""
 
diff --git a/src/cogent3/util/table.py b/src/cogent3/util/table.py
index f98da0709..11100da6e 100644
--- a/src/cogent3/util/table.py
+++ b/src/cogent3/util/table.py
@@ -514,7 +514,7 @@ class Table:
                     f"different number of elements in header {hlen} and data row 0 {dcols}"
                 )
 
-            data = {c: v for c, v in zip(header, zip(*data))}
+            data = dict(zip(header, zip(*data)))
 
         if header is None:
             header = list(data) if isinstance(data, dict) else []


Test patch:
diff --git a/tests/test_app/test_composable.py b/tests/test_app/test_composable.py
index f387cc2d2..2ad298fb8 100644
--- a/tests/test_app/test_composable.py
+++ b/tests/test_app/test_composable.py
@@ -16,7 +16,7 @@ from cogent3.app.composable import (
     Composable,
     NotCompleted,
     appify,
-    composable,
+    define_app,
     user_function,
 )
 from cogent3.app.sample import min_length, omit_degenerates
@@ -479,27 +479,33 @@ class TestUserFunction(TestCase):
 def test_app_registry():
     """correctly registers apps"""
 
-    @composable
-    class foo_1:
+    @define_app
+    class foo:
         def main(self, data: int) -> int:
             return data
 
     from cogent3.app.composable import __app_registry
 
-    assert __app_registry["test_composable.foo_1"] == "foo_1"
+    assert __app_registry["test_composable.foo"]
+
+    # delete it to not include in app available apps
+    del __app_registry["test_composable.foo"]
 
 
 def test_app_is_composable():
     """check is_composable for composable apps"""
 
-    from cogent3.app.composable import is_composable
+    from cogent3.app.composable import __app_registry, is_composable
 
-    @composable
-    class foo_2:
+    @define_app
+    class foo:
         def main(self, data: int) -> int:
             return data
 
-    assert is_composable(foo_2)
+    assert is_composable(foo)
+
+    # delete it to not include in app available apps
+    del __app_registry["test_composable.foo"]
 
 
 def test_app_is_not_composable():
@@ -507,11 +513,11 @@ def test_app_is_not_composable():
 
     from cogent3.app.composable import is_composable
 
-    class foo_3:
+    class foo:
         def main(self, data: int) -> int:
             return data
 
-    assert not is_composable(foo_3)
+    assert not is_composable(foo)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_app/test_data_store.py b/tests/test_app/test_data_store.py
index c4156e5aa..f9f20b93f 100644
--- a/tests/test_app/test_data_store.py
+++ b/tests/test_app/test_data_store.py
@@ -91,7 +91,7 @@ class DataStoreBaseReadTests:
     def test_iter(self):
         """DataStore objects allow iteration over members"""
         dstore = self.ReadClass(self.basedir, suffix=".fasta")
-        members = [m for m in dstore]
+        members = list(dstore)
         self.assertEqual(members, dstore.members)
 
     def test_len(self):
@@ -101,14 +101,14 @@ class DataStoreBaseReadTests:
 
     def test_read(self):
         """correctly read content"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            expect = {l: s for l, s in MinimalFastaParser(infile)}
+        with open(f"data{os.sep}brca1.fasta") as infile:
+            expect = dict(MinimalFastaParser(infile))
 
         dstore = self.ReadClass(self.basedir, suffix=".fasta")
         basedir = self.basedir.replace(".zip", "")
         data = dstore.read(os.path.join(basedir, "brca1.fasta"))
         data = data.splitlines()
-        got = {l: s for l, s in MinimalFastaParser(data)}
+        got = dict(MinimalFastaParser(data))
         self.assertEqual(got, expect)
 
     # todo not really broken, but something to do with line-feeds I
@@ -162,9 +162,7 @@ class DataStoreBaseWriteTests:
 
     def test_write(self):
         """correctly write content"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            expect = infile.read()
-
+        expect = Path(f"data{os.sep}brca1.fasta").read_text()
         with TemporaryDirectory(dir=".") as dirname:
             path = os.path.join(dirname, self.basedir)
             dstore = self.WriteClass(path, suffix=".fa", create=True)
@@ -191,9 +189,7 @@ class DataStoreBaseWriteTests:
     @skipIf(sys.platform.lower() != "darwin", "broken on linux")
     def test_md5_write(self):
         """tracks md5 sums of written data"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            expect = infile.read()
-
+        expect = Path(f"data{os.sep}brca1.fasta").read_text()
         with TemporaryDirectory(dir=".") as dirname:
             path = os.path.join(dirname, self.basedir)
             dstore = self.WriteClass(path, suffix=".fa", create=True)
@@ -221,12 +217,8 @@ class DataStoreBaseWriteTests:
 
     def test_multi_write(self):
         """correctly write multiple files to data store"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            expect_a = infile.read()
-
-        with open("data" + os.sep + "primates_brca1.fasta") as infile:
-            expect_b = infile.read()
-
+        expect_a = Path(f"data{os.sep}brca1.fasta").read_text()
+        expect_b = Path(f"data{os.sep}primates_brca1.fasta").read_text()
         with TemporaryDirectory(dir=".") as dirname:
             path = os.path.join(dirname, self.basedir)
             dstore = self.WriteClass(path, suffix=".fa", create=True)
@@ -243,8 +235,7 @@ class DataStoreBaseWriteTests:
 
     def test_add_file(self):
         """correctly add an arbitrarily named file"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            data = infile.read()
+        data = Path(f"data{os.sep}brca1.fasta").read_text()
 
         with TemporaryDirectory(dir=".") as dirname:
             log_path = os.path.join(dirname, "some.log")
@@ -275,7 +266,7 @@ class DataStoreBaseWriteTests:
     def test_make_identifier(self):
         """correctly construct an identifier for a new member"""
         with TemporaryDirectory(dir=".") as dirname:
-            if dirname.startswith("." + os.sep):
+            if dirname.startswith(f".{os.sep}"):
                 dirname = dirname[2:]
 
             path = os.path.join(dirname, self.basedir)
@@ -288,7 +279,7 @@ class DataStoreBaseWriteTests:
 
             # now using a DataStoreMember
             member = DataStoreMember(
-                os.path.join("blah" + os.sep + "blah", f"2-{name}"), None
+                os.path.join(f"blah{os.sep}blah", f"2-{name}"), None
             )
             got = dstore.make_absolute_identifier(member)
             expect = os.path.join(base_path, member.name.replace("fasta", "json"))
@@ -530,7 +521,7 @@ class TinyDBDataStoreTests(TestCase):
             with open(log_path, "w") as out:
                 out.write("some text")
 
-            keys = [k for k in self.data.keys()]
+            keys = list(self.data.keys())
             path = os.path.join(dirname, self.basedir)
             dstore = self.WriteClass(path, if_exists="overwrite")
             identifier = dstore.make_relative_identifier(keys[0])
@@ -542,7 +533,7 @@ class TinyDBDataStoreTests(TestCase):
     def test_tiny_get_member(self):
         """get member works on TinyDbDataStore"""
         with TemporaryDirectory(dir=".") as dirname:
-            keys = [k for k in self.data.keys()]
+            keys = list(self.data.keys())
             path = os.path.join(dirname, self.basedir)
             dstore = self.WriteClass(path, if_exists="overwrite")
             identifier = dstore.make_relative_identifier(keys[0])
@@ -562,7 +553,7 @@ class TinyDBDataStoreTests(TestCase):
                 identifier = dstore.make_relative_identifier(id_)
                 dstore.write(identifier, data)
 
-            members = [m for m in dstore]
+            members = list(dstore)
             self.assertEqual(members, dstore.members)
             dstore.close()
 
@@ -687,7 +678,7 @@ class TinyDBDataStoreTests(TestCase):
                 id_ = dstore.make_relative_identifier(k)
                 dstore.write(id_, self.data[k])
             # now add a log file
-            dstore.add_file("data" + os.sep + "scitrack.log", cleanup=False)
+            dstore.add_file(f"data{os.sep}scitrack.log", cleanup=False)
             got = dstore.describe
             # table has rows for completed, incomplete and log
             self.assertEqual(got.shape, (3, 2))
@@ -844,13 +835,13 @@ class SingleReadStoreTests(TestCase):
 
     def test_read(self):
         """correctly read content"""
-        with open("data" + os.sep + "brca1.fasta") as infile:
-            expect = {l: s for l, s in MinimalFastaParser(infile)}
+        with open(f"data{os.sep}brca1.fasta") as infile:
+            expect = dict(MinimalFastaParser(infile))
 
         dstore = self.Class(self.basedir, suffix=".fasta")
         data = dstore.read(self.basedir)
         data = data.splitlines()
-        got = {l: s for l, s in MinimalFastaParser(data)}
+        got = dict(MinimalFastaParser(data))
         self.assertEqual(got, expect)
 
 
diff --git a/tests/test_app/test_dist.py b/tests/test_app/test_dist.py
index 60dd7c6a9..8bc5354a7 100644
--- a/tests/test_app/test_dist.py
+++ b/tests/test_app/test_dist.py
@@ -206,7 +206,6 @@ class FastSlowDistTests(TestCase):
         aligner = align.progressive_align(model="WG01", guide_tree=treestring)
         _ = aligner(self.seqs5)
 
-    @pytest.mark.xfail
     def test_composes_with_write_tabular(self):
         """correctly links to tabular"""
         with TemporaryDirectory(dir=".") as dirname:
diff --git a/tests/test_app/test_evo.py b/tests/test_app/test_evo.py
index cba4e5b4a..fa6a2a331 100644
--- a/tests/test_app/test_evo.py
+++ b/tests/test_app/test_evo.py
@@ -221,7 +221,6 @@ class TestModel(TestCase):
         r = mod(aln)
         self.assertNotIsInstance(r, NotCompleted)
 
-    @pytest.mark.xfail
     def test_model_hypothesis_result_repr(self):
         """result objects __repr__ and _repr_html_ methods work correctly"""
         import re
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index 23f04d6a3..9d2d78a90 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -67,13 +67,13 @@ class TestAvailableApps(TestCase):
     @pytest.mark.xfail
     def test_composable_pairwise_applications(self):
         """Properly compose two composable applications"""
-        from cogent3.app.composable import Composable
+        from cogent3.app.composable import is_composable
 
         with TemporaryDirectory(dir=".") as dirname:
             applications = _get_all_composables(os.path.join(dirname, "delme"))
 
             for app in applications:
-                self.assertIsInstance(app, Composable)
+                self.assertTrue(is_composable(app))
 
             composable_application_tuples = [
                 (app1, app2)
@@ -95,13 +95,13 @@ class TestAvailableApps(TestCase):
     @pytest.mark.xfail
     def test_incompatible_pairwise_applications(self):
         """Properly identify two incompatible applications"""
-        from cogent3.app.composable import Composable
+        from cogent3.app.composable import is_composable
 
         with TemporaryDirectory(dir=".") as dirname:
             applications = _get_all_composables(os.path.join(dirname, "delme"))
 
             for app in applications:
-                self.assertIsInstance(app, Composable)
+                self.assertTrue(is_composable(app))
 
             incompatible_application_tuples = [
                 (app1, app2)
diff --git a/tests/test_app/test_sample.py b/tests/test_app/test_sample.py
index 91633dba7..c8966da01 100644
--- a/tests/test_app/test_sample.py
+++ b/tests/test_app/test_sample.py
@@ -440,16 +440,17 @@ class TranslateTests(TestCase):
         ]
         ccat = sample.concat()
         # triggered by first record
-        with self.assertRaises(TypeError):
-            ccat(data)
+        got = ccat(data)
+        self.assertIsInstance(got, composable.NotCompleted)
 
         # triggered by second record
-        with self.assertRaises(TypeError):
-            ccat(data[::-1])
+        got = ccat(data[::-1])
+        self.assertIsInstance(got, composable.NotCompleted)
 
         # triggered by no data
-        with self.assertRaises(ValueError):
-            ccat([])
+        got = ccat([])
+        expected = []
+        self.assertEqual(got, expected)
 
     def test_trim_stop_codons(self):
         """trims stop codons using the specified genetic code"""
diff --git a/tests/test_app/test_typing.py b/tests/test_app/test_typing.py
index 539e25289..a51b70c70 100644
--- a/tests/test_app/test_typing.py
+++ b/tests/test_app/test_typing.py
@@ -71,3 +71,6 @@ def test_hints_from_strings_invalid():
 def test_hints_from_strings():
     got = hints_from_strings("serialisable", "aligned")
     assert got == [SerialisableType, AlignedSeqsType]
+
+
+test_get_constraint_names_no_constraint()