2 # This file is part of the GROMACS molecular simulation package.
4 # Copyright (c) 2019,2020,2021, 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.
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.
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.
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.
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.
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.
36 Provide command line operation.
39 __all__ = ['commandline_operation']
48 from gmxapi import exceptions
49 from gmxapi import logger as root_logger
50 from gmxapi.datamodel import NDArray
51 from gmxapi.operation import OutputCollectionDescription
54 logger = root_logger.getChild('commandline')
55 logger.info('Importing {}'.format(__name__))
58 @functools.lru_cache()
59 def _config() -> dict:
60 """Get the GROMACS configuration detected during installation.
62 If this appears to be a useful function, it may become part of the regular
63 interface, but it is currently unadvertised.
66 from importlib.resources import open_text
67 with open_text('gmxapi', 'gmxconfig.json') as textfile:
68 config = json.load(textfile)
72 @functools.lru_cache()
73 def cli_executable() -> pathlib.Path:
74 """Report the installed GROMACS command line executable."""
75 path = _config().get('gmx_executable', None)
77 path = pathlib.Path(os.path.abspath(path))
80 raise exceptions.FeatureNotAvailableError('GROMACS installation unavailable.')
83 @functools.lru_cache()
84 def cli_bindir() -> pathlib.Path:
85 """Report the installed GROMACS binary directory."""
86 path = _config().get('gmx_bindir', None)
88 path = pathlib.Path(os.path.abspath(path))
91 raise exceptions.FeatureNotAvailableError('GROMACS installation unavailable.')
94 # Create an Operation that consumes a list and a boolean to produce a string and an integer.
96 # Wrap the defined function using a decorator that
97 # * strips the `output` parameter from the signature
98 # * provides `output` publishing proxy to the inner function and
99 # * produce a result with attributes for
100 # * file: mapping of output flags to output filenames
101 # * stdout: process STDOUT
102 # * stderr: porcess STDERR
103 # * returncode: integer return code of wrapped command
105 # Note that the existence of the 'file' output map is expressed here, but
106 # the keys of the map are not implicit or set by the wrapped function.
107 # For the map to be non-empty, it must be defined before the resulting helper
108 # function is called.
110 # TODO: Operation returns the output object when called with the shorter signature.
112 @gmx.function_wrapper(output={'stdout': str,
115 def cli(command: NDArray, shell: bool, output: OutputCollectionDescription, stdin: str = ''):
116 """Execute a command line program in a subprocess.
118 Configure an executable in a subprocess. Executes when run in an execution
119 Context, as part of a work graph or via gmx.run(). Runs in the current
122 Shell processing is not enabled, but can be considered for a future version.
123 This means that shell expansions such as environment variables, globbing (`*`),
124 and other special symbols (like `~` for home directory) are not available.
125 This allows a simpler and more robust implementation, as well as a better
126 ability to uniquely identify the effects of a command line operation. If you
127 think this disallows important use cases, please let us know.
130 command: a tuple (or list) to be the subprocess arguments, including `executable`
131 output: mapping of command line flags to output filename arguments
132 shell: unused (provides forward-compatibility)
133 stdin (str): String input to send to STDIN (terminal input) of the executable.
135 Multi-line text sent to *stdin* should be joined into a single string
136 (e.g. ``'\n'.join(list_of_strings) + '\n'``).
137 If multiple strings are provided to *stdin*, gmxapi will assume an ensemble,
138 and will run one operation for each provided string.
140 Only string input (:py:func:str) to *stdin* is currently supported.
141 If you have a use case that requires streaming input or binary input,
142 please open an issue or contact the author(s).
144 Arguments are iteratively added to the command line with standard Python
145 iteration, so you should use a tuple or list even if you have only one parameter.
146 I.e. If you provide a string with `arguments="asdf"` then it will be passed as
147 `... "a" "s" "d" "f"`. To pass a single string argument, `arguments=("asdf")`
148 or `arguments=["asdf"]`.
150 `input` and `output` should be a dictionary with string keys, where the keys
151 name command line "flags" or options.
154 Execute a command named `exe` that takes a flagged option for file name
155 (stored in a local Python variable `my_filename`) and an `origin` flag
156 that uses the next three arguments to define a vector.
158 >>> my_filename = "somefilename"
159 >>> result = cli(('exe', '--origin', 1.0, 2.0, 3.0, '-f', my_filename), shell=False)
160 >>> assert hasattr(result, 'file')
161 >>> assert hasattr(result, 'stdout')
162 >>> assert hasattr(result, 'stderr')
163 >>> assert hasattr(result, 'returncode')
166 A data structure with attributes for each of the results `file`, `stdout`, `stderr`, and `returncode`
168 Result object attributes:
169 * `file`: the mapping of CLI flags to filename strings resulting from the `output` kwarg
170 * `stdout`: A string mapping from process STDOUT.
171 * `stderr`: A string mapping from process STDERR; it will be the
172 error output (if any) if the process failed.
173 * `returncode`: return code of the subprocess.
176 # In the operation implementation, we expect the `shell` parameter to be intercepted by the
177 # wrapper and set to False.
179 raise exceptions.UsageError("Operation does not support shell processing.")
184 if isinstance(command, (str, bytes)):
186 command = list([arg for arg in command])
188 executable = shutil.which(command[0])
189 if executable is None:
190 raise exceptions.ValueError('"{}" is not found or not executable.'.format(command[0]))
191 command[0] = executable
193 # TODO: (FR9) Can OS input/output filehandles be a responsibility of
194 # the code providing 'resources'?
198 logger.debug('executing subprocess')
200 completed_process = subprocess.run(command,
204 stdout=subprocess.PIPE,
205 stderr=subprocess.PIPE,
207 universal_newlines=True
209 returncode = completed_process.returncode
210 # TODO: Resource management code should manage a safe data object for `output`.
211 logger.debug('STDOUT:')
212 if completed_process.stderr is not None:
213 for line in completed_process.stdout.split('\n'):
216 logger.debug('STDOUT is empty')
217 logger.debug('STDERR:')
218 if completed_process.stderr is not None:
219 for line in completed_process.stderr.split('\n'):
222 logger.debug('STDERR is empty')
224 stdout = completed_process.stdout
225 stderr = completed_process.stderr
227 except subprocess.CalledProcessError as e:
228 logger.info("commandline operation had non-zero return status"
229 "when calling {}".format(e.cmd))
232 returncode = e.returncode
235 output.stdout = stdout
236 output.stderr = stderr
237 output.returncode = returncode
240 # TODO: (FR4) Make this a formal operation to properly handle gmxapi data dependencies.
241 # The consumer of this operation has an NDArray input. filemap may contain gmxapi data flow
242 # aspects that we want the framework to handle for us.
243 def filemap_to_flag_list(filemap: dict = None):
244 """Convert a map of command line flags and filenames to a list of command line arguments.
246 Used to map inputs and outputs of command line tools to and from gmxapi data handles.
247 User provides mappings of flags and filenames so that gmxapi can construct an
248 executable command line.
250 Primary use case is implicit. commandline_operation() instantiates this operation based on
251 user input, and sends the output to cli()
254 filemap: key-value map of command line flags and filename arguments
257 list of strings and/or gmxapi data references
260 if filemap is not None:
261 for key, value in filemap.items():
262 # Note that the value may be a string, a list, an ndarray, or a future
263 if not isinstance(value, (list, tuple, NDArray)):
264 if hasattr(value, 'result') and value.dtype == NDArray:
266 elif hasattr(value, 'result') and value.dtype != NDArray:
267 # TODO: Fix this ugly hack when we have proper Future slicing and can make NDArray futures.
268 result_function = value.result
269 value.result = lambda function=result_function: [function()]
272 result = gmx.join_arrays(front=result, back=gmx.join_arrays(front=[key], back=value))
276 # TODO: (FR4) Use generating function or decorator that can validate kwargs?
277 # TODO: (FR4) Outputs need to be fully formed and typed in the object returned
278 # from the helper (decorated function).
279 def commandline_operation(executable=None,
281 input_files: dict = None,
282 output_files: dict = None,
285 """Helper function to define a new operation that executes a subprocess in gmxapi data flow.
287 Define a new Operation for a particular executable and input/output parameter set.
288 Generate a chain of operations to process the named key word arguments and handle
289 input/output data dependencies.
292 executable: name of an executable on the path
293 arguments: list of positional arguments to insert at ``argv[1]``
294 input_files: mapping of command-line flags to input file names
295 output_files: mapping of command-line flags to output file names
296 stdin (str): String input to send to STDIN (terminal input) of the executable (optional).
298 Multi-line text sent to *stdin* should be joined into a single string.
301 commandline_operation(..., stdin='\\n'.join(list_of_strings) + '\\n')
303 If multiple strings are provided to *stdin*, gmxapi will assume an ensemble,
304 and will run one operation for each provided string.
306 Only string input (:py:func:`str`) to *stdin* is currently supported.
307 If you have a use case that requires streaming input or binary input,
308 please open an issue or contact the author(s).
311 The output node of the resulting operation handle contains
313 * ``file``: the mapping of CLI flags to filename strings resulting from the ``output_files`` kwarg
314 * ``stdout``: A string mapping from process STDOUT.
315 * ``stderr``: A string mapping from process STDERR; it will be the
316 error output (if any) if the process failed.
317 * ``returncode``: return code of the subprocess.
321 # Implementation details: When used in a script, this function returns an
322 # instance of an operation. However, because of the dynamic specification of
323 # inputs and outputs, each invocation may have the overhead of defining new
324 # types to express the data flow topology, regardless of the executable.
325 # If this overhead is problematic, consider exposing the intermediate step
326 # at which the Operation is fully specified to facilitate reuse.
329 # 1. Define a new operation with outputs from `cli()` plus `file` from `output_files`
331 # output_files is essentially passed through, but we need assurance that results
332 # will not be published until the rest of the operation has run (i.e. the cli() executable.)
334 # Warning: decorating a local function like this is counter to the notion of Operations
335 # as portable (importable, serializable/deserializable). The big picture here needs
336 # some more consideration.
337 # TODO: (NOW) Distinguish portable Operations from relocatable Futures.
338 # There is nothing antithetical about objects implementing gmxapi data interfaces
339 # that are only resolvable by a certain Context as long as that Context can convey
340 # the results to another Context upon request. Re-instantiating Operations is
341 # only one way of relocating Futures. In this case, though, the dynamic creation of
342 # merged_ops doesn't seem right, and commandline_operation should probably be
343 # a proper Operation.
345 # TODO: (FR4+) Characterize the `file` dictionary key type:
346 # explicitly sequences rather than maybe-string/maybe-sequence-of-strings
347 @gmx.function_wrapper(output={'stdout': str,
351 def merged_ops(stdout: str = None,
353 returncode: int = None,
355 output: OutputCollectionDescription = None):
356 assert stdout is not None
357 assert stderr is not None
358 assert returncode is not None
359 assert file is not None
360 assert output is not None
361 output.returncode = returncode
362 output.stdout = stdout
363 output.stderr = stderr
370 # 2. Prepare data flow.
372 if input_files is None:
374 if output_files is None:
376 if isinstance(arguments, (str, bytes)):
377 arguments = [arguments]
378 command = gmx.concatenate_lists([[executable],
380 filemap_to_flag_list(input_files),
381 filemap_to_flag_list(output_files)])
382 shell = gmx.make_constant(False)
383 cli_args = {'command': command,
385 cli_args.update(**kwargs)
386 if stdin is not None:
387 cli_args['stdin'] = str(stdin)
390 # 3. Merge operations
392 # Note: Without a `label` argument, repeated calls to cli(**cli_args) should
393 # produce references to the same unique resource. Creating this handle
394 # separately should not be necessary, but we've got a way to go until we have the
395 # fingerprinting and Context resource management we need for that.
396 # TODO: ``label`` kwarg
397 # TODO: input fingerprinting
398 cli_result = cli(**cli_args)
399 merged_result = merged_ops(stdout=cli_result.output.stdout,
400 stderr=cli_result.output.stderr,
401 returncode=cli_result.output.returncode,
405 # Return an object with an OutputCollection granting access to outputs of
406 # cli() and of output_files (as "file")