Repository: cogent3/cogent3 (python)

Issue:
DEV add a deprecate_arguments decorator
There is a pattern to deprecating function / method arguments.

- change the signature to the new argument name(s)
- add a `**kwargs` parameter to the signature
- import the deprecated helper function
- inside the function / method, loop over `**kwargs` for deprecated args, assigning value of deprecated arg  to new parameter and trigger deprecation warning

After the warning period is finished, the signature, import and loop all need to be deleted. This is tedious. It's also quite an ask for new contributors to do all this.

Replace this functionality with a `deprecate_arguments` decorator added to `cogent3.util.misc. warning`. This decorator will allow us to change the method / function signature to its final state and removal

This decorator will:
- accept the same arguments as the utility function, but also a tuple of `old_new` pairs (old name, new name)
- intercept all calls to the wrapped method
-  assigning value of deprecated arg  to new parameter and trigger deprecation warning
- call the wrapped method with the correct signature

Pull request description:
Implement deprecate decorator
fixes #1324 


Patch:
diff --git a/changelog.d/20230303_121357_richard_issue1304.md b/changelog.d/20230303_121357_richard_issue1304.md
index 0bb2eb14c..5183af76b 100644
--- a/changelog.d/20230303_121357_richard_issue1304.md
+++ b/changelog.d/20230303_121357_richard_issue1304.md
@@ -6,9 +6,9 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 
 ### Contributers
 
-- khiron
+-   khiron
 
 ### ENH
 
-- Added automated changelog management to the project
+-   Added automated changelog management to the project
 
diff --git a/changelog.d/20230324_162931_Gavin.Huttley.md b/changelog.d/20230324_162931_Gavin.Huttley.md
deleted file mode 100644
index 11968e47e..000000000
--- a/changelog.d/20230324_162931_Gavin.Huttley.md
+++ /dev/null
@@ -1,44 +0,0 @@
-<!--
-A new scriv changelog fragment.
-
-Uncomment the section that is right (remove the HTML comment wrapper).
--->
-
-<!--
-### Contributers
-
--   A bullet item for the Contributers category.
-
--->
-<!--
-### ENH
-
--   A bullet item for the ENH category.
-
--->
-<!--
-### BUG
-
--   A bullet item for the BUG category.
-
--->
-<!--
-### DOC
-
--   A bullet item for the DOC category.
-
--->
-<!--
-### Deprecations
-
--   Removed the original Composable app classes and related decorators for
-    user based apps. `user_function` and `appify` are replaced by the
-    `define_app` decorator. 
-
--->
-<!--
-### Discontinued
-
--   A bullet item for the Discontinued category.
-
--->
diff --git a/changelog.d/20230329_162247_richard_Issue_1324_Deprecate_decorator.md b/changelog.d/20230329_162247_richard_Issue_1324_Deprecate_decorator.md
new file mode 100644
index 000000000..95f1b9c28
--- /dev/null
+++ b/changelog.d/20230329_162247_richard_Issue_1324_Deprecate_decorator.md
@@ -0,0 +1,5 @@
+### ENH
+
+- serializable deprecation of function and method arguments using decorator
+
+<!--
diff --git a/changelog.d/README.md b/changelog.d/README.md
index b89e053b7..890284fb0 100644
--- a/changelog.d/README.md
+++ b/changelog.d/README.md
@@ -15,19 +15,19 @@ Uncomment the category of change you are making and add a short description of t
 change as a markdown bullet point.  For example:
 
 Contributors
-* khiron
+*   khiron
 
 ENH
-* Added a new feature that allows users to do Y
+*   Added a new feature that allows users to do Y
 
 DEP
-* Removed deprecated feature Z
+*   Removed deprecated feature Z
 
 BUG
-* Fixed a bug that caused the project to crash when a user did X by doing Y instead
+*   Fixed a bug that caused the project to crash when a user did X by doing Y instead
 
 DOC
-* Documented feature Z
+*   Documented feature Z
 
 Check the file in with your changes.  
 
diff --git a/changelog.d/templates/new.md.j2 b/changelog.d/templates/new.md.j2
index eb8f6bd6d..0bd8c5374 100644
--- a/changelog.d/templates/new.md.j2
+++ b/changelog.d/templates/new.md.j2
@@ -8,7 +8,7 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 <!--
 ### {{ cat }}
 
-- A bullet item for the {{ cat }} category.
+-   A bullet item for the {{ cat }} category.
 
 -->
 {% endfor -%}
diff --git a/noxfile.py b/noxfile.py
index 28b318220..07af4647d 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -3,7 +3,7 @@ import pathlib
 import nox
 
 
-_py_versions = range(8, 11)
+_py_versions = range(7, 11)
 
 
 @nox.session(python=[f"3.{v}" for v in _py_versions])
diff --git a/requirements.txt b/requirements.txt
index 7de8d0489..83b9951b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
 chardet==5.1.0
 pillow==9.4.0
 psutil==5.9.4
-numpy==1.24.2
+numpy==1.23.5
 numba>0.48.0;python_version<'3.9'
 numba>0.53; python_version>='3.9'
 numba>0.54; python_version>='3.10'
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index 98ef8f148..af9887853 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -129,6 +129,651 @@ class NotCompleted(int):
         return json.dumps(self.to_rich_dict())
 
 
+class ComposableType:  # pragma: no cover
+    def __init__(self, input_types=None, output_types=None, data_types=None):
+        """
+        Parameters
+        ----------
+        input_types : str or collection of str
+            Allowed input types
+        output_types : str or collection of str
+            Types of output
+        data_types : str or collection of str
+            Allowed data types
+        """
+        from cogent3.util.misc import get_object_provenance
+
+        input_types = [] if input_types is None else input_types
+        output_types = [] if output_types is None else output_types
+        data_types = [] if data_types is None else data_types
+
+        if type(input_types) == str:
+            input_types = [input_types]
+        if type(output_types) == str:
+            output_types = [output_types]
+        if type(data_types) == str:
+            data_types = [data_types]
+
+        self._input_types = frozenset(input_types)
+        self._output_types = frozenset(output_types)
+        self._data_types = frozenset(data_types)
+        prov = get_object_provenance(self)
+        if not prov.startswith("cogent3"):
+            from warnings import catch_warnings, simplefilter
+            from warnings import warn as _warn
+
+            # we have a 3rd party composable
+            msg = (
+                "Defining composable apps by inheriting from Composable is "
+                "deprecated and will be removed in release 2022.11. This "
+                "will be a backwards incompatible change! We are moving to a"
+                " decorator for defining composable apps. For instructions "
+                f" on porting {prov!r} see"
+                " https://github.com/cogent3/cogent3/wiki/composable-functions"
+            )
+            with catch_warnings():
+                simplefilter("always")
+                _warn(msg, DeprecationWarning, 1)
+
+    def compatible_input(self, other):
+        result = other._output_types & self._input_types
+        return result != set()
+
+    def _validate_data_type(self, data):
+        """checks data class name matches defined compatible types"""
+        if not self._data_types:
+            # not defined
+            return True
+
+        name = data.__class__.__name__
+        valid = name in self._data_types
+        if not valid:
+            msg = f"invalid data type, '{name}' not in {', '.join(self._data_types)}"
+            valid = NotCompleted("ERROR", self, message=msg, source=data)
+        return valid
+
+
+class Composable(ComposableType):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(Composable, self).__init__(**kwargs)
+        self._in = None  # input rules
+        # rules operating on result but not part of a chain
+        self._checkpointable = False
+        self._load_checkpoint = None
+        self._formatted = []
+
+    def __str__(self):
+        txt = str(self.input) if self.input else ""
+        if txt:
+            txt += " + "
+        txt += f"{self.__class__.__name__}({', '.join(self._formatted)})"
+        txt = textwrap.fill(
+            txt, width=80, break_long_words=False, break_on_hyphens=False
+        )
+        return txt
+
+    def __repr__(self):
+        return str(self)
+
+    def _formatted_params(self):
+        stack = inspect.stack()
+        stack.reverse()
+        for level in stack:
+            args = inspect.getargvalues(level.frame).locals
+            klass = args.get("__class__", None)
+            if klass and isinstance(self, klass):
+                break
+        args = inspect.getargvalues(level.frame).locals
+        params = inspect.signature(args["__class__"].__init__).parameters
+        formatted = []
+        for p in params:
+            if p == "self":
+                continue
+            try:
+                v = args[p]
+            except KeyError:
+                continue
+            try:
+                v = v.name
+            except AttributeError:
+                pass
+
+            if in_jupyter():
+                if p == "kwargs" and v == {"store_history": True, "silent": False}:
+                    continue
+            formatted.append(f"{p}={v!r}")
+        self._formatted += formatted
+
+    def __add__(self, other):
+        if other is self:
+            raise ValueError("cannot add an app to itself")
+
+        if other.input:
+            # can only be part of a single composable function
+            other_name = other.__class__.__name__
+            msg = f"{other_name} already part of composed function"
+            raise AssertionError(f"{msg}, use disconnect() to free them up")
+
+        if not other.compatible_input(self):
+            msg = '%s() requires input type "%s", %s() produces "%s"'
+            my_name = self.__class__.__name__
+            my_output = self._output_types
+            their_name = other.__class__.__name__
+            their_input = other._input_types
+            msg %= (their_name, their_input, my_name, my_output)
+            raise TypeError(msg)
+        other.input = self
+        return other
+
+    def _set_checkpoint_loader(self):
+        # over-ride in subclasses that are checkpointable
+        self._load_checkpoint = None
+
+    def _make_output_identifier(self, data):
+        # over-ride in subclasses that are checkpointable
+        pass
+
+    @property
+    def checkpointable(self):
+        """whether this function is checkpointable"""
+        return self._checkpointable
+
+    def job_done(self, *args, **kwargs):
+        # over-ride in sub classes
+        return False
+
+    def _trapped_call(self, func, val, *args, **kwargs):
+        valid = self._validate_data_type(val)
+        if not valid:
+            return valid
+        try:
+            val = func(val, *args, **kwargs)
+        except Exception:
+            val = NotCompleted("ERROR", self, traceback.format_exc(), source=val)
+        return val
+
+    def __call__(self, val, *args, **kwargs):
+        # initial invocation always transfers call() to first composable
+        # element to get input for self
+        refobj = kwargs.get("identifier", val)
+        if not val:
+            return val
+
+        if self.checkpointable:
+            job_done = self.job_done(refobj)
+            if job_done:
+                result = self._make_output_identifier(refobj)
+                return result
+
+        if self.input:
+            val = self._in(val, *args, **kwargs)
+
+        if not val:
+            return val
+        result = self._trapped_call(self.main, val, *args, **kwargs)
+        if not result and type(result) != NotCompleted:
+            msg = (
+                f"The value {result} equates to False. "
+                "If unexpected, please post this error message along"
+                f" with the code and data '{val}' as an Issue on the"
+                " github project page."
+            )
+            origin = str(self)
+            result = NotCompleted("BUG", origin, msg, source=val)
+
+        return result
+
+    @property
+    def input(self):
+        return self._in
+
+    @input.setter
+    def input(self, other):
+        self._in = other
+
+    def disconnect(self):
+        """resets input and output to None
+
+        Breaks all connections among members of a composed function."""
+        input = self.input
+        if input:
+            input.disconnect()
+
+        self._in = None
+        self._out = None
+        self._load_checkpoint = None
+
+    @UI.display_wrap
+    def apply_to(
+        self,
+        dstore,
+        parallel=False,
+        par_kw=None,
+        logger=None,
+        cleanup=False,
+        ui=None,
+        **kwargs,
+    ):
+        """invokes self composable function on the provided data store
+
+        Parameters
+        ----------
+        dstore
+            a path, list of paths, or DataStore to which the process will be
+            applied.
+        parallel : bool
+            run in parallel, according to arguments in par_kwargs. If True,
+            the last step of the composable function serves as the master
+            process, with earlier steps being executed in parallel for each
+            member of dstore.
+        par_kw
+            dict of values for configuring parallel execution.
+        logger
+            Argument ignored if not an io.writer. If a scitrack logger not provided,
+            one is created with a name that defaults to the composable function names
+            and the process ID.
+        cleanup : bool
+            after copying of log files into the data store, it is deleted
+            from the original location
+
+        Returns
+        -------
+        Result of the process as a list
+
+        Notes
+        -----
+        If run in parallel, this instance serves as the master object and
+        aggregates results.
+        """
+        if isinstance(dstore, str):
+            dstore = [dstore]
+
+        dstore = [e for e in dstore if e]
+        if not dstore:
+            raise ValueError("dstore is empty")
+
+        am_writer = hasattr(self, "data_store")
+        if am_writer:
+            start = time.time()
+            if logger is None:
+                logger = CachingLogger()
+            elif not isinstance(logger, CachingLogger):
+                raise TypeError(
+                    f"logger must be scitrack.CachingLogger, not {type(logger)}"
+                )
+
+            if not logger.log_file_path:
+                src = Path(self.data_store.source)
+                logger.log_file_path = str(src.parent / _make_logfile_name(self))
+
+            log_file_path = str(logger.log_file_path)
+            logger.log_message(str(self), label="composable function")
+            logger.log_versions(["cogent3"])
+
+        results = []
+        process = self.input or self
+        if self.input:
+            # As we will be explicitly calling the input object, we disconnect
+            # the two-way interaction between input and self. This means self
+            # is not called twice, and self is not unecessarily pickled during
+            # parallel execution.
+            process.output = None
+            self.input = None
+
+        # with a tinydb dstore, this also excludes data that failed to complete
+        # todo update to consider different database backends
+        # we want a dict mapping input member names to their md5 sums so these can
+        # be logged
+        inputs = {}
+        for m in dstore:
+            input_id = Path(m if isinstance(m, DataStoreMember) else get_data_source(m))
+            suffixes = input_id.suffixes
+            input_id = input_id.name.replace("".join(suffixes), "")
+            inputs[input_id] = m
+
+        if len(inputs) < len(dstore):
+            diff = len(dstore) - len(inputs)
+            raise ValueError(
+                f"could not construct unique identifiers for {diff} records, "
+                "avoid using '.' as a delimiter in names."
+            )
+
+        if parallel:
+            par_kw = par_kw or {}
+            to_do = PAR.as_completed(process, inputs.values(), **par_kw)
+        else:
+            to_do = map(process, inputs.values())
+
+        for result in ui.series(to_do, count=len(inputs)):
+            if process is not self and am_writer:
+                # if result is NotCompleted, it will be written as incomplete
+                # by data store backend. The outcome is just the
+                # associated db identifier for tracking steps below we need to
+                # know it's NotCompleted.
+                # Note: we directly call .write() so NotCompleted's don't
+                # get blocked from being written by __call__()
+                outcome = self.main(data=result)
+                if result and isinstance(outcome, DataStoreMember):
+                    input_id = outcome.name
+                else:
+                    input_id = get_data_source(result)
+                    outcome = result
+                input_id = Path(input_id)
+                suffixes = input_id.suffixes
+                input_id = input_id.name.replace("".join(suffixes), "")
+            elif process is not self:
+                outcome = self(result)
+            else:
+                outcome = result
+
+            results.append(outcome)
+
+            if am_writer:
+                # now need to search for the source member
+                m = inputs[input_id]
+                input_md5 = getattr(m, "md5", None)
+                logger.log_message(input_id, label="input")
+                if input_md5:
+                    logger.log_message(input_md5, label="input md5sum")
+
+                if isinstance(outcome, NotCompleted):
+                    # log error/fail details
+                    logger.log_message(
+                        f"{outcome.origin} : {outcome.message}", label=outcome.type
+                    )
+                    continue
+                elif not outcome:
+                    # other cases where outcome is Falsy (e.g. None)
+                    logger.log_message(f"unexpected value {outcome!r}", label="FAIL")
+                    continue
+
+                logger.log_message(outcome, label="output")
+                logger.log_message(outcome.md5, label="output md5sum")
+
+        if am_writer:
+            finish = time.time()
+            taken = finish - start
+
+            logger.log_message(f"{taken}", label="TIME TAKEN")
+            logger.shutdown()
+            self.data_store.add_file(log_file_path, cleanup=cleanup, keep_suffix=True)
+            self.data_store.close()
+
+        # now reconnect input
+        if process is not self:
+            self = process + self
+
+        return results
+
+
+class ComposableTabular(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableTabular, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableTabular",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableSeq(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableSeq, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableSeq",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableAligned(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableAligned, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableAligned",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableTree(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableTree, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableTree",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableModel(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableModel, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableModel",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableHypothesis(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableHypothesis, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableHypothesis",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class ComposableDistance(Composable):  # pragma: no cover
+    def __init__(self, **kwargs):
+        super(ComposableDistance, self).__init__(**kwargs)
+        discontinued(
+            "class",
+            "ComposableDistance",
+            "2023.1",
+            "see developer docs for new class hierarchy",
+        )
+
+
+class _checkpointable:
+    def __init__(
+        self,
+        data_path,
+        name_callback=None,
+        create=False,
+        if_exists=SKIP,
+        suffix=None,
+        writer_class=None,
+        **kwargs,
+    ):
+        """
+        Parameters
+        ----------
+        data_path
+            path to write output
+        name_callback
+            function that takes the data object and returns a base
+            file name
+        create : bool
+            whether to create the data_path reference
+        if_exists : str
+            behaviour if output exists. Either 'skip', 'raise' (raises an
+            exception), 'overwrite', 'ignore'
+        writer_class : type
+            constructor for writer
+        """
+        data_path = str(data_path)
+
+        if data_path.endswith(".tinydb") and not self.__class__.__name__.endswith("db"):
+            raise ValueError("tinydb suffix reserved for write_db")
+
+        if_exists = if_exists.lower()
+        assert if_exists in (
+            SKIP,
+            IGNORE,
+            RAISE,
+            OVERWRITE,
+        ), "invalid value for if_exists"
+        self._if_exists = if_exists
+
+        klass = writer_class or WritableDirectoryDataStore
+        self.data_store = klass(
+            data_path, suffix=suffix, create=create, if_exists=if_exists
+        )
+        self._callback = name_callback
+
+        # override the following in subclasses
+        self._format = None
+        self._formatter = None
+        self._load_checkpoint = None
+
+    def _make_output_identifier(self, data):
+        if self._callback:
+            data = self._callback(data)
+
+        return self.data_store.make_absolute_identifier(data)
+
+    def job_done(self, data):
+        identifier = self._make_output_identifier(data)
+        exists = identifier in self.data_store
+        if exists and self._if_exists == RAISE:
+            msg = f"'{identifier}' already exists"
+            raise RuntimeError(msg)
+
+        if self._if_exists == OVERWRITE:
+            exists = False
+        return exists
+
+    def main(self, data) -> DataStoreMember:
+        # over-ride in subclass
+        raise NotImplementedError
+
+
+class user_function(Composable):  # pragma: no cover
+    """wrapper class for user specified function"""
+
+    @extend_docstring_from(ComposableType.__init__, pre=False)
+    def __init__(
+        self, func, input_types, output_types, *args, data_types=None, **kwargs
+    ):
+        """
+        func : callable
+            user specified function
+        *args
+            positional arguments to append to incoming values prior to calling
+            func
+        **kwargs
+            keyword arguments to include when calling func
+
+        Notes
+        -----
+        Known types are defined as constants in ``cogent3.app.composable``, e.g.
+        ALIGNED_TYPE, SERIALISABLE_TYPE, RESULT_TYPE.
+
+        If you create a function ``foo(arg1, arg2, kwarg1=False)``. You can
+        turn this into a user function, e.g.
+
+        >>> ufunc = user_function(foo, in_types, out_types, arg1val, kwarg1=True)
+
+        Then
+
+        >>> ufunc(arg2val) == foo(arg1val, arg2val, kwarg1=True)
+        """
+        super(user_function, self).__init__(
+            input_types=input_types, output_types=output_types, data_types=data_types
+        )
+        self._user_func = func
+        self._args = args
+        self._kwargs = kwargs
+
+    def main(self, *args, **kwargs):
+        """
+        Parameters
+        ----------
+        args
+            self._args + args are passed to the user function
+        kwargs
+            a deep copy of self._kwargs is updated by kwargs and passed to the
+            user function
+
+        Returns
+        -------
+        the result of the user function
+        """
+        args = self._args + args
+        kwargs_ = deepcopy(self._kwargs)
+        kwargs_.update(kwargs)
+        # the following enables a decorated user function (via @appify())
+        # or directly passed user function
+        func = getattr(self._user_func, "__wrapped__", self._user_func)
+        return func(*args, **kwargs_)
+
+    def __str__(self):
+        txt = str(self.input) if self.input else ""
+        if txt:
+            txt += " + "
+        txt += f"user_function(name='{self._user_func.__name__}', module='{self._user_func.__module__}')"
+        txt = textwrap.fill(
+            txt, width=80, break_long_words=False, break_on_hyphens=False
+        )
+        return txt
+
+    def __repr__(self):
+        return str(self)
+
+
+@extend_docstring_from(ComposableType.__init__, pre=True)
+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.
+
+    >>> @appify(SEQUENCE_TYPE, SEQUENCE_TYPE, data_types="SequenceCollection")
+    ... def omit_seqs(seqs, quantile=None, gap_fraction=1, moltype="dna"):
+    ...     return seqs.omit_bad_seqs(quantile=quantile, gap_fraction=gap_fraction, moltype="dna")
+    ...
+
+    ``omit_seqs()`` is now an app factory, allowing creating variants of the app.
+
+    >>> omit_bad = omit_seqs(quantile=0.95)
+
+    ``omit_bad`` is now a composable ``user_function`` app. Calling with different
+    args/kwargs values returns a variant app, as per the behaviour of builtin
+    apps.
+
+    """
+    # the 3 nested functions are required to allow setting decorator arguments
+    # to allow using functools.wraps so the decorated function has the correct
+    # docstring, name etc... And, the final inner one gets to pass the
+    # reference to the wrapped function (wrapped_ref) to user_function. This
+    # latter is required to enable pickling of the user_function instance.
+    def enclosed(func):
+        @wraps(func)
+        def maker(*args, **kwargs):
+            # construct the user_function app
+            return user_function(
+                wrapped_ref,
+                input_types,
+                output_types,
+                *args,
+                data_types=data_types,
+                **kwargs,
+            )
+
+        wrapped_ref = maker
+
+        return maker
+
+    return enclosed
+
+
 class AppType(Enum):
     LOADER = "loader"
     WRITER = "writer"
diff --git a/src/cogent3/util/warning.py b/src/cogent3/util/warning.py
index b7f3f1517..12f453c66 100644
--- a/src/cogent3/util/warning.py
+++ b/src/cogent3/util/warning.py
@@ -1,11 +1,14 @@
 #!/usr/bin/env python
+import functools
+
+from typing import Any, Callable, List, Tuple
 from warnings import catch_warnings, simplefilter
 from warnings import warn as _warn
 
 
 __author__ = "Gavin Huttley"
 __copyright__ = "Copyright 2007-2022, The Cogent Project"
-__credits__ = ["Gavin Huttley", "Jai Ram Rideout"]
+__credits__ = ["Gavin Huttley", "Jai Ram Rideout", "Richard Morris"]
 __license__ = "BSD-3"
 __version__ = "2023.2.12a1"
 __maintainer__ = "Gavin Huttley"
@@ -68,3 +71,65 @@ def discontinued(_type, name, version, reason=None, stack_level=3):
     with catch_warnings():
         simplefilter("always")
         _warn(msg, DeprecationWarning, stacklevel=stack_level)
+
+
+def deprecated_args(
+    mapping: List[Tuple[str, str]],
+    version: str,
+    reason: str,
+) -> Callable[..., Any]:
+    """
+    A decorator that marks specific arguments of a function as deprecated.
+
+    The decorator accepts a list of 2-tuples specifying the mapping of old argument names to new argument names.
+    When the decorated function is called with any of the old argument names, they will be replaced with their
+    corresponding new names in the kwargs dictionary.
+
+    Parameters
+    ----------
+    mapping : List[Tuple[str, str]]
+        A list of 2-tuples specifying the mapping of old argument names to new argument names.
+    version : str
+        A string indicating the version when the deprecated arguments will be removed.
+    reason : str
+        A string providing a reason for deprecation.
+
+    Returns
+    -------
+    Callable[..., Any]
+        The decorated function.
+
+    Warnings
+    --------
+    DeprecationWarning
+        A warning will be raised when the decorated function is called with any of the deprecated arguments.
+
+    Examples
+    --------
+    Here's an example of how to use the `deprecated_args` decorator to mark the argument `old_name` as deprecated
+    and replace it with the new name `new_name`.
+
+    >>> @deprecated_args(mapping=[('old_name', 'new_name')], version='2.0', reason='Use new_name instead')
+    >>> def my_function(new_name):
+    >>>     # do something here
+
+    When `my_function` is called with the argument `old_name`, a warning will be raised indicating that the argument
+    is deprecated and should be replaced with `new_name`.
+    """
+
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @functools.wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Callable[..., Any]:
+            message = []
+            for old, new in mapping:
+                if old in kwargs:
+                    kwargs[new] = kwargs.pop(old)
+                    message.append(f"{old}->{new}")
+            if message:
+                msg = f'The following parameters of `{func.__name__}` are deprecated. [{", ".join(message)}] will be removed in {version or "a future release"}.{f"  {reason}" if reason else ""}'
+                _warn(msg, DeprecationWarning, stacklevel=2)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator


Test patch:
diff --git a/tests/test_app/test_composable.py b/tests/test_app/test_composable.py
index 30ffbfba4..617b6855a 100644
--- a/tests/test_app/test_composable.py
+++ b/tests/test_app/test_composable.py
@@ -27,9 +27,11 @@ from cogent3.app.composable import (
     __app_registry,
     _add,
     _get_raw_hints,
+    appify,
     define_app,
     get_object_provenance,
     is_composable,
+    user_function,
 )
 from cogent3.app.data_store_new import (
     APPEND,
@@ -550,6 +552,13 @@ def _demo(ctx, expect):
     return ctx.frame_start == expect
 
 
+# for testing appify
+@appify(SERIALISABLE_TYPE, SERIALISABLE_TYPE)
+def slicer(val, index=2):
+    """my docstring"""
+    return val[:index]
+
+
 @define_app
 def foo(val: AlignedSeqsType, *args, **kwargs) -> AlignedSeqsType:
     return val[:4]
@@ -606,6 +615,26 @@ def test_user_function_multiple():
     assert got_2 == {("s1", "s2"): 2.0, ("s2", "s1"): 2.0}
 
 
+def test_appify():
+    """acts like a decorator should!"""
+    assert slicer.__doc__ == "my docstring"
+    assert slicer.__name__ == "slicer"
+    app = slicer()
+    assert SERIALISABLE_TYPE in app._input_types
+    assert SERIALISABLE_TYPE in app._output_types
+    assert app(list(range(4))) == [0, 1]
+    app2 = slicer(index=3)
+    assert app2(list(range(4))) == [0, 1, 2]
+
+
+def test_appify_pickle():
+    """appified function should be pickleable"""
+    app = slicer(index=6)
+    dumped = dumps(app)
+    loaded = loads(dumped)
+    assert loaded(list(range(10))) == list(range(6))
+
+
 def test_user_function_repr():
     got = repr(bar(num=3))
     assert got == "bar(num=3)"
@@ -616,6 +645,48 @@ def test_user_function_str():
     assert got == "bar(num=3)"
 
 
+def test_user_function_with_args_kwargs():
+    """correctly handles definition with args, kwargs"""
+    from math import log
+
+    def product(val, multiplier, take_log=False):
+        result = val * multiplier
+        if take_log:
+            result = log(result)
+
+        return result
+
+    # without defining any args, kwargs
+    ufunc = user_function(
+        product,
+        SERIALISABLE_TYPE,
+        SERIALISABLE_TYPE,
+    )
+    assert ufunc(2, 2) == 4
+    assert ufunc(2, 2, take_log=True) == log(4)
+
+    # defining default arg2
+    ufunc = user_function(
+        product,
+        SERIALISABLE_TYPE,
+        SERIALISABLE_TYPE,
+        2,
+    )
+    assert ufunc(2) == 4
+    assert ufunc(2, take_log=True) == log(4)
+
+    # defining default kwarg only
+    ufunc = user_function(product, SERIALISABLE_TYPE, SERIALISABLE_TYPE, take_log=True)
+    assert ufunc(2, 2) == log(4)
+    assert ufunc(2, 2, take_log=False) == 4
+
+    # defining default arg and kwarg
+    ufunc = user_function(
+        product, SERIALISABLE_TYPE, SERIALISABLE_TYPE, 2, take_log=True
+    )
+    assert ufunc(2) == log(4)
+
+
 def test_app_registry():
     """correctly registers apps"""
 
diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py
index b6d449583..59e8abbac 100644
--- a/tests/test_util/__init__.py
+++ b/tests/test_util/__init__.py
@@ -6,6 +6,7 @@ __all__ = [
     "test_transform",
     "test_recode_alignment",
     "test_union_dict",
+    "test_warning",
 ]
 
 __author__ = ""
@@ -18,6 +19,7 @@ __credits__ = [
     "Zongzhi Liu",
     "Amanda Birmingham",
     "Greg Caporaso",
+    "Richard Morris",
 ]
 __license__ = "BSD-3"
 __version__ = "2023.2.12a1"
diff --git a/tests/test_util/test_warning.py b/tests/test_util/test_warning.py
new file mode 100644
index 000000000..3e99f4654
--- /dev/null
+++ b/tests/test_util/test_warning.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python
+
+import pickle
+
+import pytest
+
+from cogent3.util.warning import deprecated_args
+
+
+__author__ = "Richard Morris"
+__copyright__ = "Copyright 2007-2022, The Cogent Project"
+__credits__ = ["Gavin Huttley", "Richard Morris"]
+__license__ = "BSD-3"
+__version__ = "2023.2.12a1"
+__maintainer__ = "Gavin Huttley"
+__email__ = "gavin.huttley@anu.edu.au"
+__status__ = "Production"
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_function_deprecated_args():
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(a: int, b: int) -> int:
+        return a + b
+
+    expected = changed(a=5, b=3)
+    got = changed(x=5, y=3)
+    assert got == expected
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_function_deprecated_args_docstring():
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(a: int, b: int) -> int:
+        """This is a test function"""
+        return a + b
+
+    assert changed.__doc__ == "This is a test function"
+
+
+@pytest.mark.parametrize("kwargs", (dict(x=5, y=3), dict(a=5, y=3), dict(x=5, b=3)))
+def test_function_deprecated_args_warn(kwargs):
+    # Example target function to be decorated
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(a: int, b: int) -> int:
+        return a + b
+
+    with pytest.deprecated_call():
+        changed(**kwargs)
+
+
+def test_function_correct_args_do_not_warn():
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(a: int, b: int) -> int:
+        return a + b
+
+    with pytest.warns(None) as record:  # verify no warnings for correct parameters
+        changed(a=5, b=3)
+    assert not record
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_function_deprecated_args_pickled():
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(a: int, b: int) -> int:
+        return a + b
+
+    myfunc = changed(x=1, y=2)
+    pickled_func = pickle.dumps(myfunc)
+    assert isinstance(pickled_func, bytes)
+    unpickled_func = pickle.loads(pickled_func)
+    assert unpickled_func == changed(a=1, b=2)
+
+
+class foo:
+    def __init__(self):
+        self.a = 0
+        self.b = 0
+
+    @deprecated_args(
+        [("x", "a"), ("y", "b")],
+        version="a future release",
+        reason="x and y are not descriptive",
+    )
+    def changed(self, a: int, b: int):
+        """This is a test function"""
+        self.a = a
+        self.b = b
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_method_deprecated_args():
+    foo_instance = foo()
+    foo_instance.changed(a=5, b=3)
+    assert foo_instance.a == 5
+    assert foo_instance.b == 3
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_method_deprecated_args_docstring():
+    assert foo.changed.__doc__ == "This is a test function"
+    foo_instance = foo()
+    assert foo_instance.changed.__doc__ == "This is a test function"
+
+
+@pytest.mark.parametrize("kwargs", (dict(x=5, y=3), dict(a=5, y=3), dict(x=5, b=3)))
+def test_method_deprecated_args_warn(kwargs):
+
+    with pytest.deprecated_call():
+        foo().changed(**kwargs)
+
+
+def test_method_correct_args_do_not_warn():
+    with pytest.warns(None) as record:  # verify no warnings for correct parameters
+        foo().changed(a=5, b=3)
+    assert not record
+
+
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
+def test_method_deprecated_args_pickled():
+
+    foo_instance = foo()
+    assert foo_instance.a == 0
+    assert foo_instance.b == 0
+    foo_instance.changed(x=1, y=2)
+    assert foo_instance.a == 1
+    assert foo_instance.b == 2
+
+    # serialize instance with pickle
+    pickled_foo = pickle.dumps(foo_instance)
+    assert isinstance(pickled_foo, bytes)
+
+    # deserialize instance with pickle
+    unpickled_foo = pickle.loads(pickled_foo)
+    assert isinstance(unpickled_foo, foo)
+    assert unpickled_foo.a == 1
+    assert unpickled_foo.b == 2
+    unpickled_foo.changed(x=2, y=3)
+    assert unpickled_foo.a == 2
+    assert unpickled_foo.b == 3