0166dcebe13d56e1413aefabea6a3158c377f076
[alexxy/gromacs.git] / python_packaging / src / gmxapi / commandline.py
1 #
2 # This file is part of the GROMACS molecular simulation package.
3 #
4 # Copyright (c) 2019,2020, by the GROMACS development team, led by
5 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
6 # and including many others, as listed in the AUTHORS file in the
7 # top-level source directory and at http://www.gromacs.org.
8 #
9 # GROMACS is free software; you can redistribute it and/or
10 # modify it under the terms of the GNU Lesser General Public License
11 # as published by the Free Software Foundation; either version 2.1
12 # of the License, or (at your option) any later version.
13 #
14 # GROMACS is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 # Lesser General Public License for more details.
18 #
19 # You should have received a copy of the GNU Lesser General Public
20 # License along with GROMACS; if not, see
21 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
22 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
23 #
24 # If you want to redistribute modifications to GROMACS, please
25 # consider that scientific software is very special. Version
26 # control is crucial - bugs must be traceable. We will be happy to
27 # consider code for inclusion in the official distribution, but
28 # derived work must not be called official GROMACS. Details are found
29 # in the README & COPYING files - if they are missing, get the
30 # official version at http://www.gromacs.org.
31 #
32 # To help us fund GROMACS development, we humbly ask that you cite
33 # the research papers on the package. Check out http://www.gromacs.org.
34
35 """
36 Provide command line operation.
37 """
38
39 __all__ = ['commandline_operation']
40
41 import os
42 import shutil
43 import subprocess
44
45 import gmxapi as gmx
46 from gmxapi import exceptions
47 from gmxapi import logger as root_logger
48 from gmxapi.datamodel import NDArray
49 from gmxapi.operation import OutputCollectionDescription
50
51 # Module-level logger
52 logger = root_logger.getChild('commandline')
53 logger.info('Importing {}'.format(__name__))
54
55
56 # Create an Operation that consumes a list and a boolean to produce a string and an integer.
57 #
58 # Wrap the defined function using a decorator that
59 #    * strips the `output` parameter from the signature
60 #    * provides `output` publishing proxy to the inner function and
61 #    * produce a result with attributes for
62 #       * file: mapping of output flags to output filenames
63 #       * erroroutput: text results in case of error
64 #       * returncode: integer return code of wrapped command
65 #
66 # Note that the existence of the 'file' output map is expressed here, but
67 # the keys of the map are not implicit or set by the wrapped function.
68 # For the map to be non-empty, it must be defined before the resulting helper
69 # function is called.
70 #
71 # TODO: Operation returns the output object when called with the shorter signature.
72 #
73 @gmx.function_wrapper(output={'erroroutput': str, 'returncode': int})
74 def cli(command: NDArray, shell: bool, output: OutputCollectionDescription, stdin: str = ''):
75     """Execute a command line program in a subprocess.
76
77     Configure an executable in a subprocess. Executes when run in an execution
78     Context, as part of a work graph or via gmx.run(). Runs in the current
79     working directory.
80
81     Shell processing is not enabled, but can be considered for a future version.
82     This means that shell expansions such as environment variables, globbing (`*`),
83     and other special symbols (like `~` for home directory) are not available.
84     This allows a simpler and more robust implementation, as well as a better
85     ability to uniquely identify the effects of a command line operation. If you
86     think this disallows important use cases, please let us know.
87
88     Arguments:
89          command: a tuple (or list) to be the subprocess arguments, including `executable`
90          output: mapping of command line flags to output filename arguments
91          shell: unused (provides forward-compatibility)
92          stdin (str): String input to send to STDIN (terminal input) of the executable.
93
94     Multi-line text sent to *stdin* should be joined into a single string
95     (e.g. ``'\n'.join(list_of_strings) + '\n'``).
96     If multiple strings are provided to *stdin*, gmxapi will assume an ensemble,
97     and will run one operation for each provided string.
98
99     Only string input (:py:func:str) to *stdin* is currently supported.
100     If you have a use case that requires streaming input or binary input,
101     please open an issue or contact the author(s).
102
103     Arguments are iteratively added to the command line with standard Python
104     iteration, so you should use a tuple or list even if you have only one parameter.
105     I.e. If you provide a string with `arguments="asdf"` then it will be passed as
106     `... "a" "s" "d" "f"`. To pass a single string argument, `arguments=("asdf")`
107     or `arguments=["asdf"]`.
108
109     `input` and `output` should be a dictionary with string keys, where the keys
110     name command line "flags" or options.
111
112     Example:
113         Execute a command named `exe` that takes a flagged option for file name
114         (stored in a local Python variable `my_filename`) and an `origin` flag
115         that uses the next three arguments to define a vector.
116
117             >>> my_filename = "somefilename"
118             >>> result = cli(('exe', '--origin', 1.0, 2.0, 3.0, '-f', my_filename), shell=False)
119             >>> assert hasattr(result, 'file')
120             >>> assert hasattr(result, 'erroroutput')
121             >>> assert hasattr(result, 'returncode')
122
123     Returns:
124         A data structure with attributes for each of the results `file`, `erroroutput`, and `returncode`
125
126     Result object attributes:
127         * `file`: the mapping of CLI flags to filename strings resulting from the `output` kwarg
128         * `erroroutput`: A string of error output (if any) if the process failed.
129         * `returncode`: return code of the subprocess.
130
131     """
132     # Note: we could make provisions for stdio filehandles in a future version. E.g.
133     # * STDOUT is available if a consuming operation is bound to `output.stdout`.
134     # * STDERR is available if a consuming operation is bound to `output.stderr`.
135     # * Otherwise, STDOUT and/or STDERR is(are) closed when command is called.
136
137     # In the operation implementation, we expect the `shell` parameter to be intercepted by the
138     # wrapper and set to False.
139     if shell:
140         raise exceptions.UsageError("Operation does not support shell processing.")
141
142     if stdin == '':
143         stdin = None
144
145     if isinstance(command, (str, bytes)):
146         command = [command]
147     command = list([arg for arg in command])
148
149     executable = shutil.which(command[0])
150     if executable is None:
151         raise exceptions.ValueError('"{}" is not found or not executable.'.format(command[0]))
152     command[0] = executable
153
154     # TODO: (FR9) Can OS input/output filehandles be a responsibility of
155     #  the code providing 'resources'?
156
157     erroroutput = ''
158     logger.debug('executing subprocess')
159     try:
160         completed_process = subprocess.run(command,
161                                            shell=shell,
162                                            input=stdin,
163                                            check=True,
164                                            stdout=subprocess.PIPE,
165                                            stderr=subprocess.STDOUT,
166                                            encoding='utf-8',
167                                            universal_newlines=True
168                                            )
169         returncode = completed_process.returncode
170         # TODO: Resource management code should manage a safe data object for `output`.
171         for line in completed_process.stdout.split('\n'):
172             logger.debug(line)
173     except subprocess.CalledProcessError as e:
174         logger.info("commandline operation had non-zero return status when calling {}".format(e.cmd))
175         erroroutput = e.output
176         returncode = e.returncode
177     # Publish outputs.
178     output.erroroutput = erroroutput
179     output.returncode = returncode
180
181
182 # TODO: (FR4) Make this a formal operation to properly handle gmxapi data dependencies.
183 #  The consumer of this operation has an NDArray input. filemap may contain gmxapi data flow
184 #  aspects that we want the framework to handle for us.
185 def filemap_to_flag_list(filemap: dict = None):
186     """Convert a map of command line flags and filenames to a list of command line arguments.
187
188     Used to map inputs and outputs of command line tools to and from gmxapi data handles.
189     User provides mappings of flags and filenames so that gmxapi can construct an
190     executable command line.
191
192     Primary use case is implicit. commandline_operation() instantiates this operation based on
193     user input, and sends the output to cli()
194
195     Arguments:
196         filemap: key-value map of command line flags and filename arguments
197
198     Returns:
199         list of strings and/or gmxapi data references
200     """
201     result = []
202     if filemap is not None:
203         for key, value in filemap.items():
204             # Note that the value may be a string, a list, an ndarray, or a future
205             if not isinstance(value, (list, tuple, NDArray)):
206                 if hasattr(value, 'result') and value.dtype == NDArray:
207                     pass
208                 elif hasattr(value, 'result') and value.dtype != NDArray:
209                     # TODO: Fix this ugly hack when we have proper Future slicing and can make NDArray futures.
210                     result_function = value.result
211                     value.result = lambda function=result_function: [function()]
212                 else:
213                     value = [value]
214             result = gmx.join_arrays(front=result, back=gmx.join_arrays(front=[key], back=value))
215     return result
216
217
218 # TODO: (FR4) Use generating function or decorator that can validate kwargs?
219 # TODO: (FR4) Outputs need to be fully formed and typed in the object returned
220 #  from the helper (decorated function).
221 def commandline_operation(executable=None,
222                           arguments=(),
223                           input_files: dict = None,
224                           output_files: dict = None,
225                           stdin: str = None,
226                           **kwargs):
227     """Helper function to define a new operation that executes a subprocess in gmxapi data flow.
228
229     Define a new Operation for a particular executable and input/output parameter set.
230     Generate a chain of operations to process the named key word arguments and handle
231     input/output data dependencies.
232
233     Arguments:
234         executable: name of an executable on the path
235         arguments: list of positional arguments to insert at ``argv[1]``
236         input_files: mapping of command-line flags to input file names
237         output_files: mapping of command-line flags to output file names
238         stdin (str): String input to send to STDIN (terminal input) of the executable (optional).
239
240     Multi-line text sent to *stdin* should be joined into a single string.
241     E.g.::
242
243         commandline_operation(..., stdin='\\n'.join(list_of_strings) + '\\n')
244
245     If multiple strings are provided to *stdin*, gmxapi will assume an ensemble,
246     and will run one operation for each provided string.
247
248     Only string input (:py:func:`str`) to *stdin* is currently supported.
249     If you have a use case that requires streaming input or binary input,
250     please open an issue or contact the author(s).
251
252     Output:
253         The output node of the resulting operation handle contains
254
255         * ``file``: the mapping of CLI flags to filename strings resulting from the ``output_files`` kwarg
256         * ``erroroutput``: A string of error output (if any) if the process failed.
257         * ``returncode``: return code of the subprocess.
258
259     """
260
261     # Implementation details: When used in a script, this function returns an
262     # instance of an operation. However, because of the dynamic specification of
263     # inputs and outputs, each invocation may have the overhead of defining new
264     # types to express the data flow topology, regardless of the executable.
265     # If this overhead is problematic, consider exposing the intermediate step
266     # at which the Operation is fully specified to facilitate reuse.
267
268     ##
269     # 1. Define a new operation with outputs from `cli()` plus `file` from `output_files`
270
271     # output_files is essentially passed through, but we need assurance that results
272     # will not be published until the rest of the operation has run (i.e. the cli() executable.)
273
274     # Warning: decorating a local function like this is counter to the notion of Operations
275     # as portable (importable, serializable/deserializable). The big picture here needs
276     # some more consideration.
277     # TODO: (NOW) Distinguish portable Operations from relocatable Futures.
278     # There is nothing antithetical about objects implementing gmxapi data interfaces
279     # that are only resolvable by a certain Context as long as that Context can convey
280     # the results to another Context upon request. Re-instantiating Operations is
281     # only one way of relocating Futures. In this case, though, the dynamic creation of
282     # merged_ops doesn't seem right, and commandline_operation should probably be
283     # a proper Operation.
284     #
285     # TODO: (FR4+) Characterize the `file` dictionary key type:
286     #  explicitly sequences rather than maybe-string/maybe-sequence-of-strings
287     @gmx.function_wrapper(output={'erroroutput': str, 'returncode': int, 'file': dict})
288     def merged_ops(erroroutput: str = None, returncode: int = None, file: dict = None,
289                    output: OutputCollectionDescription = None):
290         assert erroroutput is not None
291         assert returncode is not None
292         assert file is not None
293         assert output is not None
294         output.returncode = returncode
295         output.erroroutput = erroroutput
296         if returncode == 0:
297             output.file = file
298         else:
299             output.file = {}
300
301     ##
302     # 2. Prepare data flow.
303
304     if input_files is None:
305         input_files = {}
306     if output_files is None:
307         output_files = {}
308     if isinstance(arguments, (str, bytes)):
309         arguments = [arguments]
310     command = gmx.concatenate_lists([[executable],
311                                      arguments,
312                                      filemap_to_flag_list(input_files),
313                                      filemap_to_flag_list(output_files)])
314     shell = gmx.make_constant(False)
315     cli_args = {'command': command,
316                 'shell': shell}
317     cli_args.update(**kwargs)
318     if stdin is not None:
319         cli_args['stdin'] = str(stdin)
320
321     ##
322     # 3. Merge operations
323     #
324     # Note: Without a `label` argument, repeated calls to cli(**cli_args) should
325     # produce references to the same unique resource. Creating this handle
326     # separately should not be necessary, but we've got a way to go until we have the
327     # fingerprinting and Context resource management we need for that.
328     # TODO: ``label`` kwarg
329     # TODO: input fingerprinting
330     cli_result = cli(**cli_args)
331     merged_result = merged_ops(erroroutput=cli_result.output.erroroutput,
332                                returncode=cli_result.output.returncode,
333                                file=output_files,
334                                **kwargs)
335
336     # Return an object with an OutputCollection granting access to outputs of
337     # cli() and of output_files (as "file")
338     return merged_result