Introduce gmxapi operations and basic data model.
authorM. Eric Irrgang <ericirrgang@gmail.com>
Fri, 29 Mar 2019 12:59:54 +0000 (15:59 +0300)
committerM. Eric Irrgang <ericirrgang@gmail.com>
Tue, 9 Apr 2019 12:15:28 +0000 (15:15 +0300)
Ref: #2894

Change-Id: I4e899e63287370fd2fdde89ddc1b2ea39c3fe67d

python_packaging/src/gmxapi/__init__.py
python_packaging/src/gmxapi/operation.py [new file with mode: 0644]
python_packaging/src/test/test_operation.py [moved from python_packaging/src/test/test_import.py with 52% similarity]

index 4839558f42a792b53f64d10c1d7649052f8dfc78..2d4ffbc863b0f7d4ee6c9b78fbcb5524b7756d10 100644 (file)
@@ -36,6 +36,7 @@
 
 # Import system facilities
 import logging
+
 logging.getLogger().addHandler(logging.NullHandler(level=logging.DEBUG))
 logging.getLogger().setLevel(logging.DEBUG)
 logging.getLogger().info("Setting up logging for gmxapi package.")
@@ -43,4 +44,6 @@ logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 logger.info("Importing gmxapi.")
 
-__all__ = []
+__all__ = ['operation']
+
+from gmxapi import operation
diff --git a/python_packaging/src/gmxapi/operation.py b/python_packaging/src/gmxapi/operation.py
new file mode 100644 (file)
index 0000000..7757980
--- /dev/null
@@ -0,0 +1,197 @@
+# This file is part of the GROMACS molecular simulation package.
+#
+# Copyright (c) 2019, by the GROMACS development team, led by
+# Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
+# and including many others, as listed in the AUTHORS file in the
+# top-level source directory and at http://www.gromacs.org.
+#
+# GROMACS is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation; either version 2.1
+# of the License, or (at your option) any later version.
+#
+# GROMACS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with GROMACS; if not, see
+# http://www.gnu.org/licenses, or write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
+#
+# If you want to redistribute modifications to GROMACS, please
+# consider that scientific software is very special. Version
+# control is crucial - bugs must be traceable. We will be happy to
+# consider code for inclusion in the official distribution, but
+# derived work must not be called official GROMACS. Details are found
+# in the README & COPYING files - if they are missing, get the
+# official version at http://www.gromacs.org.
+#
+# To help us fund GROMACS development, we humbly ask that you cite
+# the research papers on the package. Check out http://www.gromacs.org.
+
+"""Define gmxapi-compliant Operations
+
+Provide decorators and base classes to generate and validate gmxapi Operations.
+"""
+
+import functools
+import inspect
+
+__all__ = ['computed_result',
+           'append_list',
+           'concatenate_lists'
+           ]
+
+from gmxapi import exceptions
+
+
+class ImmediateResult(object):
+    """Data handle for a simple result.
+
+    Instances of this class can be used to provide a gmxapi compatible data
+    handle for trivial operations. Operation and result are stateless and can be
+    evaluated in any Context.
+
+    Used internally to implement the computed_result factory. The interface for
+    this class will evolve as the gmxapi data model evolves. Generally, code
+    providing gmxapi data sources should use one of the factories or decorators
+    provided in the gmxapi.operation module rather than instantiating from this
+    class directly.
+    """
+
+    def __init__(self, implementation=None, input=None):
+        """Wrap a callable for a simple data source that does not need Future behavior.
+
+        Provides a gmxapi compatible interface for data sources.
+
+        Arguments:
+            implementation : Python callable that consumes ``input`` and returns data
+            input : object compatible with the call signature of ``implementation``
+
+        ``input`` must have an ``args`` attribute and a ``kwargs`` attribute to be used as
+
+            implementation(*input.args, **input.kwargs)
+
+        Callers should not assume when or how often ``implementation`` could be called.
+        Only suitable for function objects without side effects.
+        """
+        assert callable(implementation)
+        assert hasattr(input, 'args')
+        assert hasattr(input, 'kwargs')
+        # Retain input information for introspection.
+        self.__input = input
+
+        self.__cached_value = implementation(*input.args, **input.kwargs)
+        # TODO: (FR4) need a utility to resolve the base type of a value
+        #  that may be a proxy object.
+        self._dtype = type(self.__cached_value)
+
+    @property
+    def dtype(self):
+        """The data type of the return value for the wrapped function."""
+        return self._dtype
+
+    def result(self):
+        """Return value of the wrapped function."""
+        return self.__cached_value
+
+
+def computed_result(function):
+    """Decorate a function to get a helper that produces an object with Result behavior.
+
+    When called, the new function produces an ImmediateResult object.
+
+    The new function has the same signature as the original function, but can accept
+    gmxapi data proxies, assuming the provided proxy objects represent types
+    compatible with the original signature.
+
+    Calls to `result()` return the value that `function` would return when executed
+    in the local context with the inputs fully resolved.
+
+    The API does not specify when input data dependencies will be resolved
+    or when the wrapped function will be executed. That is, ``@computed_result``
+    functions may force immediate resolution of data dependencies and/or may
+    be called more than once to satisfy dependent operation inputs.
+    """
+
+    @functools.wraps(function)
+    def new_function(*args, **kwargs):
+        # The signature of the new function will accept abstractions
+        # of whatever types it originally accepted. This wrapper must
+        # * Create a mapping to the original call signature from `input`
+        # * Add handling for typed abstractions in wrapper function.
+        # * Process arguments to the wrapper function into `input`
+
+        sig = inspect.signature(function)
+        # Note: Introspection could fail.
+        # TODO: Figure out what to do with exceptions where this introspection
+        #  and rebinding won't work.
+        # ref: https://docs.python.org/3/library/inspect.html#introspecting-callables-with-the-signature-object
+
+        # TODO: (FR3+) create a serializable data structure for inputs discovered
+        #  from function introspection.
+
+        # TODO: (FR4) handle typed abstractions in input arguments
+
+        input_list = []
+        for arg in args:
+            if hasattr(arg, 'result'):
+                input_list.append(arg.result())
+            else:
+                input_list.append(arg)
+        input_dict = {}
+        for name, value in kwargs.items():
+            if hasattr(value, 'result'):
+                input_dict[name] = value.result()
+            else:
+                input_dict[name] = value
+
+        input_pack = sig.bind(*input_list, **input_dict)
+
+        result_object = ImmediateResult(function, input_pack)
+        return result_object
+
+    return new_function
+
+
+@computed_result
+def append_list(a: list = (), b: list = ()):
+    """Operation that consumes two lists and produces a concatenated single list."""
+    # TODO: (FR3) Each sublist or sublist element could be a "future" handle;
+    #  make sure input provider resolves that.
+    # TODO: (FR4) Returned list should be an NDArray.
+    if isinstance(a, (str, bytes)) or isinstance(b, (str, bytes)):
+        raise exceptions.ValueError('Input must be a pair of lists.')
+    return list(a) + list(b)
+
+
+def concatenate_lists(sublists: list = ()):
+    """Combine data sources into a single list.
+
+    A trivial data flow restructuring operation
+    """
+    if isinstance(sublists, (str, bytes)):
+        raise exceptions.ValueError('Input must be a list of lists.')
+    if len(sublists) == 1:
+        return make_constant(sublists[0])
+    if len(sublists) == 2:
+        return append_list(sublists[0], sublists[1])
+    else:
+        return append_list(sublists[0], concatenate_lists(sublists[1:]))
+
+
+@computed_result
+def make_constant(value):
+    """Provide a predetermined value at run time.
+
+    This is a trivial operation that provides a (typed) value, primarily for
+    internally use to manage gmxapi data flow.
+
+    Accepts a value of any type. The object returned has a definite type and
+    provides same interface as other gmxapi outputs. Additional constraints or
+    guarantees on data type may appear in future versions.
+    """
+    # TODO: (FR4+) Manage type compatibility with gmxapi data interfaces.
+    return type(value)(value)
similarity index 52%
rename from python_packaging/src/test/test_import.py
rename to python_packaging/src/test/test_operation.py
index 3ed948b4ab5991ce302b8fa51bf081d06ea471c2..41fabd180f256ebe2d25fcb1ad57e4a47ded92eb 100644 (file)
@@ -1,3 +1,4 @@
+#!/usr/bin/env python
 #
 # This file is part of the GROMACS molecular simulation package.
 #
 # To help us fund GROMACS development, we humbly ask that you cite
 # the research papers on the package. Check out http://www.gromacs.org.
 
-# TODO: (FR1) remove when there is something else to test
-def test_import():
-    import gmxapi
+import unittest
+
+import gmxapi as gmx
+
+
+class ImmediateResultTestCase(unittest.TestCase):
+    """Test data model and data flow for basic operations."""
+
+    def test_scalar(self):
+        operation = gmx.operation.make_constant(42)
+        assert isinstance(operation.dtype, type)
+        assert operation.dtype == int
+        assert operation.result() == 42
+
+    def test_list(self):
+        list_a = [1, 2, 3]
+
+        # TODO: test input validation
+        list_result = gmx.operation.concatenate_lists(sublists=[list_a])
+        # TODO: should be NDArray
+        assert list_result.dtype == type(list_a)
+        # Note: this is specifically for the built-in tuple type.
+        # Equality comparison may work differently for different sequence types.
+        assert tuple(list_result.result()) == tuple(list_a)
+        assert len(list_result.result()) == len(list_a)
+
+        list_result = gmx.operation.concatenate_lists([list_a, list_a])
+        assert len(list_result.result()) == len(list_a) * 2
+        assert tuple(list_result.result()) == tuple(list_a + list_a)
+
+        list_b = gmx.operation.make_constant([42])
+
+        list_result = gmx.operation.concatenate_lists(sublists=[list_b])
+        assert list_result.result()[0] == 42
+
+        list_result = gmx.operation.append_list(list_a, list_b)
+        assert len(list_result.result()) == len(list_a) + 1
+        assert tuple(list_result.result()) == tuple(list(list_a) + [42])
+
+
+if __name__ == '__main__':
+    unittest.main()